04上限に近づく列の見出し
列のカード数÷上限の比率で見出しのゲージと背景の濃さを上げ、満杯の列は受け付けず、置いたカードは元の列に戻る。
使いどころ進行中の作業数の上限の案内
WIP_LIMITPRESSURE_LERPRETURN_KTINT_MAX

<main class="stage">
<div class="kb is-demo" id="kb"><div class="kb__board" data-role="board"><div class="kb__track" data-role="track"><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">未着手</span><span class="kb__count" data-role="count"></span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span><span class="kb__count" data-role="count"></span><span class="kb__gauge" data-role="gauge"></span></div><div class="kb__tint" data-role="tint"></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span><span class="kb__count" data-role="count"></span></div></div><div class="kb__ghost" data-role="ghost"></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">ログイン画面</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">決済連携</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">通知設定</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="3" aria-pressed="false"><span class="kb__label" data-role="label">検索フィルター</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">文書の整理</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">不具合修正</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">リリース確認</span></div></div><div class="kb__cursor" data-role="cursor"></div></div><p class="kb__meta" data-role="meta"></p></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 열의 카드 수 ÷ 한도 비율을 보간해 게이지와 배경 농도를 올리고, 찬 열은 카드를 되돌려 보낸다.
// 좌표는 JS 가 계산해 transform 으로만 넣는다. 열·카드 크기는 화면 높이에 맞춘 4px 격자 한 단위(U)에서 나온다.
$bg: $stage-ink;
$panel: $tick-panel;
$text: $stage-paper;
$mute: rgba($stage-paper, .78);
$ring: $stage-paper;
@include stage($bg);
.kb {
width: min(100%, 440px);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: $sp-2;
position: relative;
}
.kb__board {
position: relative;
}
.kb__track { position: absolute; inset: 0; will-change: transform; }
.kb__col {
position: absolute;
top: 0;
box-sizing: border-box;
border-radius: $r-sm;
background: $panel;
overflow: hidden;
}
.kb__head {
position: relative;
box-sizing: border-box;
height: var(--head, 20px);
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: 0 $sp-2;
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: $text;
}
.kb__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kb__count { font-variant-numeric: tabular-nums; color: $mute; font-weight: 600; }
.kb__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
margin: 0;
display: flex;
align-items: center;
gap: $sp-1;
padding: 0 $sp-2;
border: 0;
border-radius: $r-xs;
font: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-align: left;
color: $subject-ink;
cursor: grab;
user-select: none;
touch-action: none;
box-shadow: 0 calc(var(--lift-px, 0px) * .5) var(--lift-px, 0px) rgba($stage-ink, .4), 0 1px 0 rgba($stage-ink, .14);
}
.kb__card[data-tone="0"] { background: $subject-lilac; }
.kb__card[data-tone="1"] { background: $subject-sky; }
.kb__card[data-tone="2"] { background: $subject-mint; }
.kb__card[data-tone="3"] { background: $subject-pink; }
.kb__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kb__card:focus-visible,
.kb__card.is-focus { outline: var(--focus-ring, 2px) solid $ring; outline-offset: 2px; }
.is-held { z-index: 4; cursor: grabbing; }
.kb__ghost {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px dashed rgba($ring, .6);
border-radius: $r-xs;
opacity: 0;
pointer-events: none;
}
.kb__cursor {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
box-sizing: border-box;
border: 2px solid $ring;
border-radius: 50%;
background: rgba($ring, .16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: $mute;
font-variant-numeric: tabular-nums;
}
.kb__tint { position: absolute; inset: 0; background: $subject-pink; opacity: 0; pointer-events: none; }
// 게이지는 시작 변(왼쪽)에서 자란다 — 중심이 아니라 붙는 변이 기준이라 transform-origin 이 0 50% 다
.kb__gauge {
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: 0;
height: 4px;
border-radius: $r-pill;
background: $subject-mint;
transform-origin: 0 50%;
transform: scaleX(0);
}
.is-blocked .kb__gauge { background: $subject-pink; }
@media (max-height: 259px) {
.kb__head, .kb__card { font-size: 11px; }
}
@media (max-width: 340px) {
.kb__card { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.kb__card, .kb__track, .kb__ghost { transition: none; animation: none; }
.kb__cursor { display: 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);
}
.kb {
width: min(100%, 440px);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
position: relative;
}
.kb__board {
position: relative;
}
.kb__track {
position: absolute;
inset: 0;
will-change: transform;
}
.kb__col {
position: absolute;
top: 0;
box-sizing: border-box;
border-radius: 8px;
background: #231f28;
overflow: hidden;
}
.kb__head {
position: relative;
box-sizing: border-box;
height: var(--head, 20px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px;
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: #ffffff;
}
.kb__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__count {
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.78);
font-weight: 600;
}
.kb__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
margin: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 0;
border-radius: 4px;
font: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-align: left;
color: #17141a;
cursor: grab;
user-select: none;
touch-action: none;
box-shadow: 0 calc(var(--lift-px, 0px) * 0.5) var(--lift-px, 0px) rgba(23, 20, 26, 0.4), 0 1px 0 rgba(23, 20, 26, 0.14);
}
.kb__card[data-tone="0"] {
background: #b9a0ff;
}
.kb__card[data-tone="1"] {
background: #6ec8ff;
}
.kb__card[data-tone="2"] {
background: #4cd4a6;
}
.kb__card[data-tone="3"] {
background: #ff9ebb;
}
.kb__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__card:focus-visible,
.kb__card.is-focus {
outline: var(--focus-ring, 2px) solid #ffffff;
outline-offset: 2px;
}
.is-held {
z-index: 4;
cursor: grabbing;
}
.kb__ghost {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px dashed rgba(255, 255, 255, 0.6);
border-radius: 4px;
opacity: 0;
pointer-events: none;
}
.kb__cursor {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
box-sizing: border-box;
border: 2px solid #ffffff;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
font-variant-numeric: tabular-nums;
}
.kb__tint {
position: absolute;
inset: 0;
background: #ff9ebb;
opacity: 0;
pointer-events: none;
}
.kb__gauge {
position: absolute;
left: 8px;
right: 8px;
bottom: 0;
height: 4px;
border-radius: 999px;
background: #4cd4a6;
transform-origin: 0 50%;
transform: scaleX(0);
}
.is-blocked .kb__gauge {
background: #ff9ebb;
}
@media (max-height: 259px) {
.kb__head, .kb__card {
font-size: 11px;
}
}
@media (max-width: 340px) {
.kb__card {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb__card, .kb__track, .kb__ghost {
transition: none;
animation: none;
}
.kb__cursor {
display: none;
}
}(function () {
var root = document.getElementById('kb');
var CLS = { demo: 'is-demo', held: 'is-held', blocked: 'is-blocked' };
var off = [];
var raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function q(sel) {
return root.querySelector(sel);
}
function qa(sel) {
return Array.prototype.slice.call(root.querySelectorAll(sel));
}
function on(target, type, fn) {
target.addEventListener(type, fn);
off.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 칸 크기는 화면 높이에 맞춰 U(4 또는 6) 한 단위로 정한다
var U = 4;
var CARD_H = 24;
var STEP = 28;
var HEAD = 20;
var PAD = 4;
var GAP = 8;
var onUnit = function () { };
function setUnit() {
U = window.innerHeight >= 260 ? 6 : 4;
CARD_H = 6 * U;
STEP = 7 * U;
HEAD = 5 * U;
PAD = U;
GAP = 2 * PAD;
onUnit();
}
var CYCLE_MS = 2000;
var board = q('[data-role="board"]');
var cursorEl = q('[data-role="cursor"]');
var metaEl = q('[data-role="meta"]');
var colEls = qa('[data-role="col"]');
var COLS = colEls.length;
var boardW = 0;
var boardH = 0;
var colW = 0;
var cols = [];
var cards = qa('[data-role="card"]').map(function (el, i) {
return { el: el, i: i, x: 0, y: 0, vx: 0, vy: 0, rot: 0, sc: 1, lift: 0 };
});
var demo = !reduce;
var nowMs = 0;
var lastMeta = '';
var onStop = function () { };
var afterStep = function (dt) { };
var extraSettle = function () { };
function slotX(c) {
return c * (colW + GAP) + PAD;
}
function slotY(r) {
return HEAD + 2 * PAD + r * STEP;
}
function measure() {
setUnit();
boardW = board.clientWidth;
colW = FIXED_COL > 0 ? FIXED_COL : (boardW - GAP * (COLS - 1)) / COLS;
boardH = slotY(ROWS - 1) + CARD_H + PAD;
board.style.height = boardH + 'px';
board.style.setProperty('--head', HEAD + 'px');
colEls.forEach(function (el, c) {
el.style.left = c * (colW + GAP) + 'px';
el.style.width = colW + 'px';
el.style.height = boardH + 'px';
});
cards.forEach(function (c) {
c.el.style.width = (colW - 2 * PAD) + 'px';
c.el.style.height = CARD_H + 'px';
});
}
function locate(i) {
for (var c = 0; c < cols.length; c++) {
var r = cols[c].indexOf(i);
if (r >= 0) return { c: c, r: r };
}
return { c: 0, r: 0 };
}
function spring(c, tx, ty, k, d, dt) {
c.vx += (k * (tx - c.x) - d * c.vx) * dt;
c.x += c.vx * dt;
c.vy += (k * (ty - c.y) - d * c.vy) * dt;
c.y += c.vy * dt;
}
function draw() {
cards.forEach(function (c) {
c.el.style.transform = 'translate3d(' + c.x.toFixed(2) + 'px,' + c.y.toFixed(2) + 'px,0) rotate(' + c.rot.toFixed(2) + 'deg) scale(' + c.sc.toFixed(3) + ')';
});
}
function cardAt(t) {
var n = t;
while (n && n !== board) {
for (var k = 0; k < cards.length; k++) {
if (cards[k].el === n) return cards[k];
}
n = n.parentElement;
}
return null;
}
function showCursor(p) {
if (!p) { cursorEl.style.opacity = '0'; return; }
cursorEl.style.opacity = '1';
cursorEl.style.transform = 'translate3d(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px,0)';
}
// keys: [시각 ms, x, y] 목록. 키 사이는 smoothstep 으로 잇는다
function keyAt(t, keys) {
var i = 0;
while (i < keys.length - 2 && t > keys[i + 1][0]) i++;
var a = keys[i];
var b = keys[i + 1];
var u = clamp((t - a[0]) / (b[0] - a[0]), 0, 1);
u = u * u * (3 - 2 * u);
return { x: a[1] + (b[1] - a[1]) * u, y: a[2] + (b[2] - a[2]) * u };
}
// 열·행 소수 좌표를 보드 픽셀로 바꾼다 (행 r 의 카드 중심선이 소수 r)
function pathPoint(t, keys) {
var k = keyAt(t, keys);
return { x: k.x * (colW + GAP) + colW / 2, y: slotY(0) + k.y * STEP + CARD_H / 2 };
}
function settleNow() {
cards.forEach(function (c) {
var t = targetOf(c);
c.x = t.x; c.y = t.y; c.vx = 0; c.vy = 0; c.rot = 0; c.sc = 1; c.lift = 0;
});
extraSettle();
}
// reduce: 보간 없이 입력 직후 목표 위치를 바로 그린다
function sync() {
if (!reduce) return;
settleNow();
draw();
report();
}
function report() {
var m = metaText();
if (m !== lastMeta) { lastMeta = m; metaEl.textContent = m; }
}
function stopDemo() {
if (!demo) return;
demo = false;
root.classList.remove(CLS.demo);
showCursor(null);
onStop();
}
function settleClass(c, h) {
if (c.el.classList.contains(CLS.held) && Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1.5 && Math.abs(c.vx) + Math.abs(c.vy) < 30) {
c.el.classList.remove(CLS.held);
}
}
var t0 = -1;
var lastTs = 0;
var prevT = 0;
function frame(ts) {
if (t0 < 0) { t0 = ts; lastTs = ts; }
var dt = clamp((ts - lastTs) / 1000, 0.001, 0.033);
lastTs = ts;
nowMs = ts;
var t = (ts - t0) % CYCLE_MS;
if (demo) {
if (t < prevT) demoReset();
demoAt(t);
}
prevT = t;
cards.forEach(function (c) { stepCard(c, dt); });
afterStep(dt);
draw();
report();
raf = requestAnimationFrame(frame);
}
function boot() {
measure();
cols = initialCols();
settleNow();
draw();
report();
on(window, 'resize', function () { measure(); settleNow(); draw(); });
if (reduce) {
root.classList.remove(CLS.demo);
} else {
raf = requestAnimationFrame(frame);
}
}
var held = null;
var ptr = { x: 0, y: 0 };
var grabDX = 0;
var grabDY = 0;
onStop = function () { if (held) release(); };
function local(e) {
var r = board.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function kbd(e) {
var c = cardAt(e.target);
if (!c) return;
var k = e.key;
if (k === ' ' || k === 'Enter') {
e.preventDefault();
stopDemo();
if (held === c) {
release();
} else if (!held) {
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
sync();
return;
}
if (held !== c) return;
if (k === 'Escape') { e.preventDefault(); cancel(); sync(); return; }
var dx = k === 'ArrowRight' ? colW + GAP : k === 'ArrowLeft' ? -(colW + GAP) : 0;
var dy = k === 'ArrowDown' ? STEP : k === 'ArrowUp' ? -STEP : 0;
if (dx !== 0 || dy !== 0) { e.preventDefault(); moveTo(ptr.x + dx, ptr.y + dy); sync(); }
}
on(board, 'pointerdown', function (e) {
var pe = e;
var c = cardAt(pe.target);
if (!c) return;
stopDemo();
if (held) release();
var p = local(pe);
board.setPointerCapture(pe.pointerId);
grab(c, p.x, p.y);
sync();
});
on(board, 'pointermove', function (e) {
if (!held || demo) return;
var p = local(e);
moveTo(p.x, p.y);
sync();
});
function pointerEnd() {
if (held && !demo) { release(); sync(); }
}
on(board, 'pointerup', pointerEnd);
on(board, 'pointercancel', pointerEnd);
on(root, 'keydown', function (e) { kbd(e); });
var WIP_LIMIT = 4;
var PRESSURE_LERP = 0.15;
var RETURN_K = 150;
var TINT_MAX = 0.5;
var MIDLINE_BIAS = 0;
var DOING = 1;
var ROWS = 4;
var FIXED_COL = 0;
var tgt = { x: 0, y: 0 };
var ins = null;
var origin = { c: 0, r: 0 };
var blocked = false;
var returning = null;
var pressure = 0;
var demoGrabbed = false;
var KEYS_A = [[200, 0, 0], [600, 1, 2.6]];
var KEYS_B = [[850, 0, 0], [1250, 1, 2.6]];
var gauge = q('[data-role="gauge"]');
var tint = q('[data-role="tint"]');
var counts = qa('[data-role="count"]');
function insertionAt(px, py) {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost() {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt) {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
function initialCols() {
return [[0, 1, 2], [3, 4, 5], [6]];
}
function targetOf(c) {
if (c === held) return tgt;
var s = locate(c.i);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += STEP;
return { x: slotX(s.c), y: y };
}
// 열의 카드 수 ÷ 한도. 집은 카드가 그 열 위에 있으면 하나 더 들어온 것으로 미리 센다
function pressureGoal() {
var n = cols[DOING].length;
if (held) n = blocked ? WIP_LIMIT : n + (ins && ins.c === DOING ? 1 : 0);
return Math.min(1, n / WIP_LIMIT);
}
function grab(c, px, py) {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
blocked = false;
held = c;
returning = null;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
c.el.classList.add(CLS.held);
c.el.setAttribute('aria-pressed', 'true');
}
function moveTo(px, py) {
ptr = { x: clamp(px, 0, boardW), y: clamp(py, 0, boardH) };
tgt = { x: ptr.x - grabDX, y: ptr.y - grabDY };
var s = insertionAt(ptr.x, ptr.y);
blocked = s.c === DOING && cols[DOING].length >= WIP_LIMIT;
ins = blocked ? null : s;
}
// 한도에 찬 열이면 카드는 원래 자리로 돌아간다 (RETURN_K 스프링이 한 번 넘었다 돌아온다)
function release() {
if (!held) return;
var s = ins ? ins : origin;
if (!ins) returning = held;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
ins = null;
blocked = false;
}
function cancel() {
ins = null;
release();
}
function stepCard(c, dt) {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
if (c === returning) {
spring(c, h.x, h.y, RETURN_K, 12, dt);
if (Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1 && Math.abs(c.vx) + Math.abs(c.vy) < 20) returning = null;
} else {
spring(c, h.x, h.y, 170, 21, dt);
}
c.lift += (0 - c.lift) * (1 - Math.pow(0.75, f));
settleClass(c, h);
}
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * 14).toFixed(1) + 'px');
}
function paintPressure() {
gauge.style.transform = 'scaleX(' + pressure.toFixed(3) + ')';
tint.style.opacity = (Math.pow(pressure, 3) * TINT_MAX).toFixed(3);
colEls[DOING].classList.toggle(CLS.blocked, blocked);
counts.forEach(function (el, c) {
el.textContent = c === DOING ? cols[c].length + '/' + WIP_LIMIT : String(cols[c].length);
});
}
afterStep = function (dt) {
pressure += (pressureGoal() - pressure) * (1 - Math.pow(1 - PRESSURE_LERP, dt * 60));
paintPressure();
ghostStep(dt);
};
extraSettle = function () {
pressure = pressureGoal();
paintPressure();
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function drive(c, t, keys) {
if (held !== c) grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
var p = pathPoint(t, keys);
moveTo(p.x, p.y);
showCursor(ptr);
}
function demoAt(t) {
if (t >= 200 && t < 700) {
drive(cards[0], t, KEYS_A);
} else if (t >= 850 && t < 1400) {
drive(cards[1], t, KEYS_B);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset() {
cols = initialCols();
ins = null;
blocked = false;
returning = null;
}
function metaText() {
return 'WIP_LIMIT ' + WIP_LIMIT + ' · pressure ' + pressure.toFixed(2);
}
boot();
})();// WipLimit — vanilla 판(kbn-wip-limit-pressure) 의 React 포트. 열의 카드 수 ÷ 한도 비율을 보간해 게이지와 배경 농도를 올리고, 찬 열은 카드를 되돌려 보낸다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./WipLimit.module.scss";
export default function WipLimit() {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
type Off = () => void;
type Step = (dt: number) => void;
type Card = { el: HTMLElement; i: number; x: number; y: number; vx: number; vy: number; rot: number; sc: number; lift: number };
type Pt = { x: number; y: number };
type Slot = { c: number; r: number };
var root: HTMLElement = rootEl;
var CLS = { demo: styles["is-demo"], held: styles["is-held"], blocked: styles["is-blocked"] };
var off: Off[] = [];
var raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function q(sel: string): HTMLElement {
return root.querySelector(sel) as HTMLElement;
}
function qa(sel: string): HTMLElement[] {
return Array.prototype.slice.call(root.querySelectorAll(sel)) as HTMLElement[];
}
function on(target: EventTarget, type: string, fn: EventListener): void {
target.addEventListener(type, fn);
off.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 칸 크기는 화면 높이에 맞춰 U(4 또는 6) 한 단위로 정한다
var U = 4;
var CARD_H = 24;
var STEP = 28;
var HEAD = 20;
var PAD = 4;
var GAP = 8;
var onUnit: Off = function () { };
function setUnit(): void {
U = window.innerHeight >= 260 ? 6 : 4;
CARD_H = 6 * U;
STEP = 7 * U;
HEAD = 5 * U;
PAD = U;
GAP = 2 * PAD;
onUnit();
}
var CYCLE_MS = 2000;
var board = q('[data-role="board"]');
var cursorEl = q('[data-role="cursor"]');
var metaEl = q('[data-role="meta"]');
var colEls = qa('[data-role="col"]');
var COLS = colEls.length;
var boardW = 0;
var boardH = 0;
var colW = 0;
var cols: number[][] = [];
var cards: Card[] = qa('[data-role="card"]').map(function (el: HTMLElement, i: number): Card {
return { el: el, i: i, x: 0, y: 0, vx: 0, vy: 0, rot: 0, sc: 1, lift: 0 };
});
var demo = !reduce;
var nowMs = 0;
var lastMeta = '';
var onStop: Off = function () { };
var afterStep: Step = function (dt: number) { };
var extraSettle: Off = function () { };
function slotX(c: number): number {
return c * (colW + GAP) + PAD;
}
function slotY(r: number): number {
return HEAD + 2 * PAD + r * STEP;
}
function measure(): void {
setUnit();
boardW = board.clientWidth;
colW = FIXED_COL > 0 ? FIXED_COL : (boardW - GAP * (COLS - 1)) / COLS;
boardH = slotY(ROWS - 1) + CARD_H + PAD;
board.style.height = boardH + 'px';
board.style.setProperty('--head', HEAD + 'px');
colEls.forEach(function (el: HTMLElement, c: number) {
el.style.left = c * (colW + GAP) + 'px';
el.style.width = colW + 'px';
el.style.height = boardH + 'px';
});
cards.forEach(function (c: Card) {
c.el.style.width = (colW - 2 * PAD) + 'px';
c.el.style.height = CARD_H + 'px';
});
}
function locate(i: number): Slot {
for (var c = 0; c < cols.length; c++) {
var r = cols[c].indexOf(i);
if (r >= 0) return { c: c, r: r };
}
return { c: 0, r: 0 };
}
function spring(c: Card, tx: number, ty: number, k: number, d: number, dt: number): void {
c.vx += (k * (tx - c.x) - d * c.vx) * dt;
c.x += c.vx * dt;
c.vy += (k * (ty - c.y) - d * c.vy) * dt;
c.y += c.vy * dt;
}
function draw(): void {
cards.forEach(function (c: Card) {
c.el.style.transform = 'translate3d(' + c.x.toFixed(2) + 'px,' + c.y.toFixed(2) + 'px,0) rotate(' + c.rot.toFixed(2) + 'deg) scale(' + c.sc.toFixed(3) + ')';
});
}
function cardAt(t: EventTarget | null): Card | null {
var n = t as HTMLElement | null;
while (n && n !== board) {
for (var k = 0; k < cards.length; k++) {
if (cards[k].el === n) return cards[k];
}
n = n.parentElement;
}
return null;
}
function showCursor(p: Pt | null): void {
if (!p) { cursorEl.style.opacity = '0'; return; }
cursorEl.style.opacity = '1';
cursorEl.style.transform = 'translate3d(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px,0)';
}
// keys: [시각 ms, x, y] 목록. 키 사이는 smoothstep 으로 잇는다
function keyAt(t: number, keys: number[][]): Pt {
var i = 0;
while (i < keys.length - 2 && t > keys[i + 1][0]) i++;
var a = keys[i];
var b = keys[i + 1];
var u = clamp((t - a[0]) / (b[0] - a[0]), 0, 1);
u = u * u * (3 - 2 * u);
return { x: a[1] + (b[1] - a[1]) * u, y: a[2] + (b[2] - a[2]) * u };
}
// 열·행 소수 좌표를 보드 픽셀로 바꾼다 (행 r 의 카드 중심선이 소수 r)
function pathPoint(t: number, keys: number[][]): Pt {
var k = keyAt(t, keys);
return { x: k.x * (colW + GAP) + colW / 2, y: slotY(0) + k.y * STEP + CARD_H / 2 };
}
function settleNow(): void {
cards.forEach(function (c: Card) {
var t = targetOf(c);
c.x = t.x; c.y = t.y; c.vx = 0; c.vy = 0; c.rot = 0; c.sc = 1; c.lift = 0;
});
extraSettle();
}
// reduce: 보간 없이 입력 직후 목표 위치를 바로 그린다
function sync(): void {
if (!reduce) return;
settleNow();
draw();
report();
}
function report(): void {
var m = metaText();
if (m !== lastMeta) { lastMeta = m; metaEl.textContent = m; }
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove(CLS.demo);
showCursor(null);
onStop();
}
function settleClass(c: Card, h: Pt): void {
if (c.el.classList.contains(CLS.held) && Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1.5 && Math.abs(c.vx) + Math.abs(c.vy) < 30) {
c.el.classList.remove(CLS.held);
}
}
var t0 = -1;
var lastTs = 0;
var prevT = 0;
function frame(ts: number): void {
if (t0 < 0) { t0 = ts; lastTs = ts; }
var dt = clamp((ts - lastTs) / 1000, 0.001, 0.033);
lastTs = ts;
nowMs = ts;
var t = (ts - t0) % CYCLE_MS;
if (demo) {
if (t < prevT) demoReset();
demoAt(t);
}
prevT = t;
cards.forEach(function (c: Card) { stepCard(c, dt); });
afterStep(dt);
draw();
report();
raf = requestAnimationFrame(frame);
}
function boot(): void {
measure();
cols = initialCols();
settleNow();
draw();
report();
on(window, 'resize', function () { measure(); settleNow(); draw(); });
if (reduce) {
root.classList.remove(CLS.demo);
} else {
raf = requestAnimationFrame(frame);
}
}
var held: Card | null = null;
var ptr: Pt = { x: 0, y: 0 };
var grabDX = 0;
var grabDY = 0;
onStop = function () { if (held) release(); };
function local(e: PointerEvent): Pt {
var r = board.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function kbd(e: KeyboardEvent): void {
var c = cardAt(e.target);
if (!c) return;
var k = e.key;
if (k === ' ' || k === 'Enter') {
e.preventDefault();
stopDemo();
if (held === c) {
release();
} else if (!held) {
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
sync();
return;
}
if (held !== c) return;
if (k === 'Escape') { e.preventDefault(); cancel(); sync(); return; }
var dx = k === 'ArrowRight' ? colW + GAP : k === 'ArrowLeft' ? -(colW + GAP) : 0;
var dy = k === 'ArrowDown' ? STEP : k === 'ArrowUp' ? -STEP : 0;
if (dx !== 0 || dy !== 0) { e.preventDefault(); moveTo(ptr.x + dx, ptr.y + dy); sync(); }
}
on(board, 'pointerdown', function (e: Event) {
var pe = e as PointerEvent;
var c = cardAt(pe.target);
if (!c) return;
stopDemo();
if (held) release();
var p = local(pe);
board.setPointerCapture(pe.pointerId);
grab(c, p.x, p.y);
sync();
});
on(board, 'pointermove', function (e: Event) {
if (!held || demo) return;
var p = local(e as PointerEvent);
moveTo(p.x, p.y);
sync();
});
function pointerEnd(): void {
if (held && !demo) { release(); sync(); }
}
on(board, 'pointerup', pointerEnd);
on(board, 'pointercancel', pointerEnd);
on(root, 'keydown', function (e: Event) { kbd(e as KeyboardEvent); });
var WIP_LIMIT = 4;
var PRESSURE_LERP = 0.15;
var RETURN_K = 150;
var TINT_MAX = 0.5;
var MIDLINE_BIAS = 0;
var DOING = 1;
var ROWS = 4;
var FIXED_COL = 0;
var tgt: Pt = { x: 0, y: 0 };
var ins: Slot | null = null;
var origin: Slot = { c: 0, r: 0 };
var blocked = false;
var returning: Card | null = null;
var pressure = 0;
var demoGrabbed = false;
var KEYS_A: number[][] = [[200, 0, 0], [600, 1, 2.6]];
var KEYS_B: number[][] = [[850, 0, 0], [1250, 1, 2.6]];
var gauge = q('[data-role="gauge"]');
var tint = q('[data-role="tint"]');
var counts = qa('[data-role="count"]');
function insertionAt(px: number, py: number): Slot {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost(): void {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt: number): void {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
function initialCols(): number[][] {
return [[0, 1, 2], [3, 4, 5], [6]];
}
function targetOf(c: Card): Pt {
if (c === held) return tgt;
var s = locate(c.i);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += STEP;
return { x: slotX(s.c), y: y };
}
// 열의 카드 수 ÷ 한도. 집은 카드가 그 열 위에 있으면 하나 더 들어온 것으로 미리 센다
function pressureGoal(): number {
var n = cols[DOING].length;
if (held) n = blocked ? WIP_LIMIT : n + (ins && ins.c === DOING ? 1 : 0);
return Math.min(1, n / WIP_LIMIT);
}
function grab(c: Card, px: number, py: number): void {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
blocked = false;
held = c;
returning = null;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
c.el.classList.add(CLS.held);
c.el.setAttribute('aria-pressed', 'true');
}
function moveTo(px: number, py: number): void {
ptr = { x: clamp(px, 0, boardW), y: clamp(py, 0, boardH) };
tgt = { x: ptr.x - grabDX, y: ptr.y - grabDY };
var s = insertionAt(ptr.x, ptr.y);
blocked = s.c === DOING && cols[DOING].length >= WIP_LIMIT;
ins = blocked ? null : s;
}
// 한도에 찬 열이면 카드는 원래 자리로 돌아간다 (RETURN_K 스프링이 한 번 넘었다 돌아온다)
function release(): void {
if (!held) return;
var s = ins ? ins : origin;
if (!ins) returning = held;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
ins = null;
blocked = false;
}
function cancel(): void {
ins = null;
release();
}
function stepCard(c: Card, dt: number): void {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
if (c === returning) {
spring(c, h.x, h.y, RETURN_K, 12, dt);
if (Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1 && Math.abs(c.vx) + Math.abs(c.vy) < 20) returning = null;
} else {
spring(c, h.x, h.y, 170, 21, dt);
}
c.lift += (0 - c.lift) * (1 - Math.pow(0.75, f));
settleClass(c, h);
}
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * 14).toFixed(1) + 'px');
}
function paintPressure(): void {
gauge.style.transform = 'scaleX(' + pressure.toFixed(3) + ')';
tint.style.opacity = (Math.pow(pressure, 3) * TINT_MAX).toFixed(3);
colEls[DOING].classList.toggle(CLS.blocked, blocked);
counts.forEach(function (el: HTMLElement, c: number) {
el.textContent = c === DOING ? cols[c].length + '/' + WIP_LIMIT : String(cols[c].length);
});
}
afterStep = function (dt: number) {
pressure += (pressureGoal() - pressure) * (1 - Math.pow(1 - PRESSURE_LERP, dt * 60));
paintPressure();
ghostStep(dt);
};
extraSettle = function () {
pressure = pressureGoal();
paintPressure();
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function drive(c: Card, t: number, keys: number[][]): void {
if (held !== c) grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
var p = pathPoint(t, keys);
moveTo(p.x, p.y);
showCursor(ptr);
}
function demoAt(t: number): void {
if (t >= 200 && t < 700) {
drive(cards[0], t, KEYS_A);
} else if (t >= 850 && t < 1400) {
drive(cards[1], t, KEYS_B);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset(): void {
cols = initialCols();
ins = null;
blocked = false;
returning = null;
}
function metaText(): string {
return 'WIP_LIMIT ' + WIP_LIMIT + ' · pressure ' + pressure.toFixed(2);
}
boot();
return () => {
cancelAnimationFrame(raf);
off.forEach(function (fn: Off) { fn(); });
};
}, []);
return (
<main className={styles["stage"]}><div className={styles["kb"] + " " + styles["is-demo"]} ref={rootRef}><div className={styles["kb__board"]} data-role="board"><div className={styles["kb__track"]} data-role="track"><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>할 일</span><span className={styles["kb__count"]} data-role="count"></span></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>진행 중</span><span className={styles["kb__count"]} data-role="count"></span><span className={styles["kb__gauge"]} data-role="gauge"></span></div><div className={styles["kb__tint"]} data-role="tint"></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>완료</span><span className={styles["kb__count"]} data-role="count"></span></div></div><div className={styles["kb__ghost"]} data-role="ghost"></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="0" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">로그인 화면</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="1" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">결제 연동</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="2" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">알림 설정</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="3" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">검색 필터</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="0" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">문서 정리</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="1" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">오류 수정</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="2" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">배포 점검</span></div></div><div className={styles["kb__cursor"]} data-role="cursor"></div></div><p className={styles["kb__meta"]} data-role="meta"></p></div></main>
);
}04 上限に近づく列の見出し — 計測 累積面積 29.077% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · kanban board html css js 9パターン — 掴んだカードが追従し置く場所が開く
このデモが属する記事
kanban board html css js 9パターン — 掴んだカードが追従し置く場所が開くkanban board html css js をカードの入れ替えだけで作ると、掴んだカードはカーソルとずれ、置く場所は見えず、満杯の列にも入る — 01番・02番・04番が計算で直す。 この記事のデモをすべて見る


