HTML / CSS / JS
その他
2026/09/28
2026/9/22
タスクリストに物理演算を持ち込んだら、こうなりました。
カードが重力に従って積み上がり、ドラッグすれば他のカードを押しのけて崩れ、チェックを入れると横にスライドして消え、残ったカードがガタガタと落下して再び安定する――そんなタスクタワーを、外部ライブラリなしのバニラJavaScriptだけで実装したデモです。
内部には衝突検出(SAT)、衝突応答(インパルスベース)、摩擦・反発・慣性を備えた本格的な2D剛体物理エンジンが組み込まれており、タスク管理という実用的なUIにゲームエンジンさながらの物理挙動を組み合わせた実験的な作品です。
<main class="kumonosu-stage" data-stroke="on" aria-label="CSSやJSで作る、掴んで崩せる楽しいタスクタワー">
<div class="kumonosu-twr" style="width:300px;height:320px">
<ul class="kumonosu-twr-pile" aria-label="やることリスト">
</ul>
</div>
</main>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #101217;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.kumonosu-stage {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
touch-action: none;
--card: #30343c;
--fill-on: #a9c4ff;
--fill-slab: #30343c;
--focus: #8eb4ff;
--ink: #f1f3f7;
--ink-2: #e3e7ef;
--ink-4: #a0a9b9;
--ink-rgb: 241, 243, 247;
--on-ink: #172033;
--pane-edge: #444a55;
}
.kumonosu-stage>.kumonosu-twr {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(var(--kumonosu-scale, 1));
transform-origin: center;
}
[data-stroke="on"] .kumonosu-twr-card {
outline: 1px solid var(--pane-edge);
outline-offset: -1px;
}
.kumonosu-twr {
position: relative;
-webkit-user-select: none;
user-select: none;
}
.kumonosu-twr-pile {
position: absolute;
inset: 0;
list-style: none;
margin: 0;
padding: 0;
}
.kumonosu-twr-row {
position: absolute;
left: 50%;
bottom: 0;
touch-action: none;
}
.kumonosu-twr-card {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
padding: 0 14px;
border-radius: 11px;
background: var(--fill-slab, var(--card));
transform-origin: 50% 50%;
cursor: grab;
transition: translate 260ms cubic-bezier(0.36, 0, 0.7, 0.2), rotate 260ms cubic-bezier(0.36, 0, 0.7, 0.2), opacity 200ms ease 60ms;
}
.kumonosu-twr-row[data-out] .kumonosu-twr-card {
translate: 118% 0;
rotate: 5deg;
opacity: 0;
transition-delay: 90ms, 90ms, 150ms;
}
.kumonosu-twr-row[data-hand] {
z-index: 3;
}
.kumonosu-twr-row[data-hand] .kumonosu-twr-card {
cursor: grabbing;
translate: 0 -2px;
}
.kumonosu-twr-check {
flex: none;
width: 19px;
height: 19px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
box-shadow: inset 0 0 0 1.5px rgba(var(--ink-rgb), 0.22);
color: var(--on-ink);
cursor: pointer;
transition: background-color 140ms ease, box-shadow 140ms ease;
}
.kumonosu-twr-check svg {
opacity: 0;
transition: opacity 110ms ease;
}
.kumonosu-twr-check:hover {
box-shadow: inset 0 0 0 1.25px rgba(var(--ink-rgb), 0.38);
}
.kumonosu-twr-row[data-out] .kumonosu-twr-check {
background: var(--fill-on, var(--ink));
box-shadow: inset 0 0 0 1.25px var(--fill-on, var(--ink));
}
.kumonosu-twr-row[data-out] .kumonosu-twr-check svg {
opacity: 1;
}
.kumonosu-twr-check:focus-visible {
outline: 2px solid var(--focus, #4c8dff);
outline-offset: 2px;
}
.kumonosu-twr-text {
font-size: 15px;
line-height: 1;
letter-spacing: -0.005em;
color: var(--ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kumonosu-twr-row[data-out] .kumonosu-twr-text {
text-decoration: line-through;
color: var(--ink-4);
}
@media (prefers-reduced-motion: reduce) {
.kumonosu-twr-card {
transition-duration: 1ms, 1ms, 1ms;
}
}
@media (prefers-reduced-motion: reduce) {
.kumonosu-twr-check, .kumonosu-twr-check svg {
transition-duration: 1ms;
}
}
(() => {
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const CW = 206;
const CH = 46;
const HW = CW / 2;
const HH = CH / 2;
const MAX = 8;
const W = 300;
const BASE_H = 320;
const boxH = (n) => Math.max(BASE_H, n * CH + 40);
const INSET = 10;
const grav = (v) => 900 + (clamp(v, 0, 100) / 100) * 3300;
const RESTITUTION = 0;
const frictionOf = (sl) => 0.85 - sl * 0.65;
const DT = 1 / 240;
const MAXSTEPS = 16;
const REST_V = 6;
const REST_W = 0.04;
const REST_N = 60;
const GRAB = 4;
const VMAX = 1800;
const SCRAPE = 150;
const LEAN = [-0.42, 0.68, -0.15, 0.51, -0.86, 0.24, 0.73, -0.57];
const TODOS = ["ナディアに返信する", "ドメインを更新する", "航空券を予約する", "アイコンを書き出す", "請求書の支払いをする", "大家さんに電話する", "バックアップを取る", "植物に水をやる", ];
const FLOOR = 100,
LEFT = 101,
RIGHT = 102,
ROOF = 103;
const tower = (n, sl, fr, lift = 0) => Array.from({
length: n
}, (_, i) => box(i, LEAN[i % LEAN.length] * sl * 16, HH + (n - 1 - i) * CH + lift, HW, HH, fr, ), );
function box(id, x, y, hw, hh, friction, mass = 1) {
const still = mass === 0;
return {
id,
x,
y,
a: 0,
vx: 0,
vy: 0,
w: 0,
hw,
hh,
friction,
invM: still ? 0 : 1 / mass,
invI: still ? 0 : 12 / (mass * (4 * hw * hw + 4 * hh * hh)),
};
}
const massOf = (hw, hh) => ({
invM: 1,
invI: 12 / (4 * hw * hw + 4 * hh * hh)
});
const E1 = 1,
E2 = 2,
E3 = 3,
E4 = 4;
const keyOf = (k) => k[0] | (k[1] << 4) | (k[2] << 8) | (k[3] << 12);
function incident(hx, hy, px, py, c, s, nx0, ny0) {
const nx = -(c * nx0 + s * ny0);
const ny = -(-s * nx0 + c * ny0);
let a, b;
if (Math.abs(nx) > Math.abs(ny)) {
if (nx > 0) {
a = {
x: hx,
y: -hy,
k: [0, 0, E3, E4]
};
b = {
x: hx,
y: hy,
k: [0, 0, E4, E1]
};
} else {
a = {
x: -hx,
y: hy,
k: [0, 0, E1, E2]
};
b = {
x: -hx,
y: -hy,
k: [0, 0, E2, E3]
};
}
} else if (ny > 0) {
a = {
x: hx,
y: hy,
k: [0, 0, E4, E1]
};
b = {
x: -hx,
y: hy,
k: [0, 0, E1, E2]
};
} else {
a = {
x: -hx,
y: -hy,
k: [0, 0, E2, E3]
};
b = {
x: hx,
y: -hy,
k: [0, 0, E3, E4]
};
}
for (const v of [a, b]) {
const x = v.x,
y = v.y;
v.x = px + c * x - s * y;
v.y = py + s * x + c * y;
}
return [a, b];
}
function clip(vin, nx, ny, offset, edge) {
const out = [];
const d0 = nx * vin[0].x + ny * vin[0].y - offset;
const d1 = nx * vin[1].x + ny * vin[1].y - offset;
if (d0 <= 0) out.push(vin[0]);
if (d1 <= 0) out.push(vin[1]);
if (d0 * d1 < 0) {
const t = d0 / (d0 - d1);
const v = {
x: vin[0].x + t * (vin[1].x - vin[0].x),
y: vin[0].y + t * (vin[1].y - vin[0].y),
k: d0 > 0 ? [...vin[0].k] : [...vin[1].k],
};
if (d0 > 0) {
v.k[0] = edge;
v.k[2] = 0;
} else {
v.k[1] = edge;
v.k[3] = 0;
}
out.push(v);
}
return out;
}
function collide(A, B) {
const cA = Math.cos(A.a),
sA = Math.sin(A.a);
const cB = Math.cos(B.a),
sB = Math.sin(B.a);
const dpx = B.x - A.x,
dpy = B.y - A.y;
const dAx = cA * dpx + sA * dpy,
dAy = -sA * dpx + cA * dpy;
const dBx = cB * dpx + sB * dpy,
dBy = -sB * dpx + cB * dpy;
const a11 = Math.abs(cA * cB + sA * sB),
a12 = Math.abs(-cA * sB + sA * cB);
const a21 = Math.abs(-sA * cB + cA * sB),
a22 = Math.abs(sA * sB + cA * cB);
const fAx = Math.abs(dAx) - A.hw - (a11 * B.hw + a12 * B.hh);
const fAy = Math.abs(dAy) - A.hh - (a21 * B.hw + a22 * B.hh);
if (fAx > 0 || fAy > 0) return [];
const fBx = Math.abs(dBx) - (a11 * A.hw + a21 * A.hh) - B.hw;
const fBy = Math.abs(dBy) - (a12 * A.hw + a22 * A.hh) - B.hh;
if (fBx > 0 || fBy > 0) return [];
const REL = 0.95,
ABS = 0.01;
let axis = 0;
let sep = fAx;
let nx = dAx > 0 ? cA : -cA,
ny = dAx > 0 ? sA : -sA;
if (fAy > REL * sep + ABS * A.hh) {
axis = 1;
sep = fAy;
nx = dAy > 0 ? -sA : sA;
ny = dAy > 0 ? cA : -cA;
}
if (fBx > REL * sep + ABS * B.hw) {
axis = 2;
sep = fBx;
nx = dBx > 0 ? cB : -cB;
ny = dBx > 0 ? sB : -sB;
}
if (fBy > REL * sep + ABS * B.hh) {
axis = 3;
sep = fBy;
nx = dBy > 0 ? -sB : sB;
ny = dBy > 0 ? cB : -cB;
}
let fnx, fny, front, snx, sny, neg, pos;
let negE, posE, inc;
if (axis === 0 || axis === 1) {
fnx = nx;
fny = ny;
const hmain = axis === 0 ? A.hw : A.hh;
const hside = axis === 0 ? A.hh : A.hw;
front = A.x * fnx + A.y * fny + hmain;
if (axis === 0) {
snx = -sA;
sny = cA;
negE = E3;
posE = E1;
} else {
snx = cA;
sny = sA;
negE = E2;
posE = E4;
}
const side = A.x * snx + A.y * sny;
neg = -side + hside;
pos = side + hside;
inc = incident(B.hw, B.hh, B.x, B.y, cB, sB, fnx, fny);
} else {
fnx = -nx;
fny = -ny;
const hmain = axis === 2 ? B.hw : B.hh;
const hside = axis === 2 ? B.hh : B.hw;
front = B.x * fnx + B.y * fny + hmain;
if (axis === 2) {
snx = -sB;
sny = cB;
negE = E3;
posE = E1;
} else {
snx = cB;
sny = sB;
negE = E2;
posE = E4;
}
const side = B.x * snx + B.y * sny;
neg = -side + hside;
pos = side + hside;
inc = incident(A.hw, A.hh, A.x, A.y, cA, sA, fnx, fny);
}
const c1 = clip(inc, -snx, -sny, neg, negE);
if (c1.length < 2) return [];
const c2 = clip(c1, snx, sny, pos, posE);
if (c2.length < 2) return [];
const out = [];
for (const v of c2) {
const s = fnx * v.x + fny * v.y - front;
if (s > 0) continue;
const k = v.k;
const key = axis >= 2 ? keyOf([k[2], k[3], k[0], k[1]]) : keyOf(k);
out.push({
x: v.x - s * fnx,
y: v.y - s * fny,
nx,
ny,
sep: s,
key,
pn: 0,
pt: 0,
mn: 0,
mt: 0,
bias: 0,
fresh: true,
});
}
return out;
}
const world = (gravity, restitution) => ({
bodies: [],
gravity,
restitution,
arbiters: new Map(),
held: null,
});
const GRIP = 8;
const CARRY = 1.6;
const isStatic = (b) => b.invM === 0 && b.invI === 0;
function remove(W, id) {
W.bodies = W.bodies.filter((b) => b.id !== id);
for (const [k, arb] of W.arbiters)
if (arb.a.id === id || arb.b.id === id) W.arbiters.delete(k);
}
const SLOP = 0.5;
const BIAS = 0.2;
const PUSH = 90;
const RVEL = 90;
const ITER = 12;
function step(W, dt, hit) {
const inv = 1 / dt;
const B = W.bodies;
let hardest = 0;
for (let i = 0; i < B.length; i++) {
for (let j = i + 1; j < B.length; j++) {
const a = B[i],
b = B[j];
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (a.invM === 0 && b.invM === 0 && a.invI === 0 && b.invI === 0) {
W.arbiters.delete(key);
continue;
}
const r = Math.hypot(a.hw, a.hh) + Math.hypot(b.hw, b.hh);
const cs = (b.x - a.x) ** 2 + (b.y - a.y) ** 2 > r * r ? [] : collide(a, b);
if (!cs.length) {
W.arbiters.delete(key);
continue;
}
const old = W.arbiters.get(key);
if (old && old.a === a) {
for (const c of cs) {
const o = old.cs.find((p) => p.key === c.key);
if (o) {
c.pn = o.pn;
c.pt = o.pt;
c.fresh = false;
}
}
}
W.arbiters.set(key, {
a,
b,
cs,
friction: Math.sqrt(a.friction * b.friction)
});
}
}
const held = W.held === null ? null : B.find((x) => x.id === W.held) ?? null;
if (held) {
const carried = new Set([held, ...riders(W, held)]);
for (const arb of W.arbiters.values()) {
if (carried.has(arb.a) && carried.has(arb.b)) arb.friction = CARRY * Math.sqrt(arb.a.friction * arb.b.friction);
else if (arb.a === held || arb.b === held) arb.friction = 0;
}
}
for (const b of B) {
if (b.invM === 0) continue;
b.vy -= W.gravity * dt;
b.w *= 0.995;
}
for (const {
a,
b,
cs
}
of W.arbiters.values()) {
for (const c of cs) {
const r1x = c.x - a.x,
r1y = c.y - a.y;
const r2x = c.x - b.x,
r2y = c.y - b.y;
const rn1 = r1x * c.nx + r1y * c.ny;
const rn2 = r2x * c.nx + r2y * c.ny;
const kn = a.invM + b.invM + a.invI * (r1x * r1x + r1y * r1y - rn1 * rn1) + b.invI * (r2x * r2x + r2y * r2y - rn2 * rn2);
c.mn = kn > 1e-12 ? 1 / kn : 0;
const tx = c.ny,
ty = -c.nx;
const rt1 = r1x * tx + r1y * ty;
const rt2 = r2x * tx + r2y * ty;
const kt = a.invM + b.invM + a.invI * (r1x * r1x + r1y * r1y - rt1 * rt1) + b.invI * (r2x * r2x + r2y * r2y - rt2 * rt2);
c.mt = kt > 1e-12 ? 1 / kt : 0;
c.bias = Math.min(PUSH, -BIAS * inv * Math.min(0, c.sep + SLOP));
const dvx = b.vx - b.w * r2y - a.vx + a.w * r1y;
const dvy = b.vy + b.w * r2x - a.vy - a.w * r1x;
const vn = dvx * c.nx + dvy * c.ny;
if (c.fresh && vn < -RVEL) {
c.bias = Math.max(c.bias, -W.restitution * vn);
hardest = Math.max(hardest, -vn);
}
const px = c.pn * c.nx + c.pt * tx;
const py = c.pn * c.ny + c.pt * ty;
a.vx -= a.invM * px;
a.vy -= a.invM * py;
a.w -= a.invI * (r1x * py - r1y * px);
b.vx += b.invM * px;
b.vy += b.invM * py;
b.w += b.invI * (r2x * py - r2y * px);
}
}
for (let it = 0; it < ITER; it++) {
for (const {
a,
b,
cs,
friction
}
of W.arbiters.values()) {
for (const c of cs) {
const r1x = c.x - a.x,
r1y = c.y - a.y;
const r2x = c.x - b.x,
r2y = c.y - b.y;
let dvx = b.vx - b.w * r2y - a.vx + a.w * r1y;
let dvy = b.vy + b.w * r2x - a.vy - a.w * r1x;
const vn = dvx * c.nx + dvy * c.ny;
let dpn = c.mn * (-vn + c.bias);
const pn0 = c.pn;
c.pn = Math.max(pn0 + dpn, 0);
dpn = c.pn - pn0;
let px = dpn * c.nx,
py = dpn * c.ny;
a.vx -= a.invM * px;
a.vy -= a.invM * py;
a.w -= a.invI * (r1x * py - r1y * px);
b.vx += b.invM * px;
b.vy += b.invM * py;
b.w += b.invI * (r2x * py - r2y * px);
dvx = b.vx - b.w * r2y - a.vx + a.w * r1y;
dvy = b.vy + b.w * r2x - a.vy - a.w * r1x;
const tx = c.ny,
ty = -c.nx;
const vt = dvx * tx + dvy * ty;
let dpt = c.mt * -vt;
const max = friction * c.pn;
const pt0 = c.pt;
c.pt = Math.min(max, Math.max(-max, pt0 + dpt));
dpt = c.pt - pt0;
px = dpt * tx;
py = dpt * ty;
a.vx -= a.invM * px;
a.vy -= a.invM * py;
a.w -= a.invI * (r1x * py - r1y * px);
b.vx += b.invM * px;
b.vy += b.invM * py;
b.w += b.invI * (r2x * py - r2y * px);
}
}
}
for (const b of B) {
b.x += b.vx * dt;
b.y += b.vy * dt;
b.a += b.w * dt;
}
if (hit && hardest > 0) hit(hardest);
}
const release = (_b) => {};
function steer(W, b, tx, ty, dt, {
speed = 2400,
accel = 40000,
turn = 14,
tvx = 0,
tvy = 0,
} = {}, ) {
const ex = tx - b.x,
ey = ty - b.y;
const dist = Math.hypot(ex, ey);
const close = dist > 0 ? Math.min(speed, dist / (dt * 8), Math.sqrt(1.2 * accel * dist)) / dist : 0;
let wantX = tvx + ex * close;
let wantY = tvy + ey * close;
const len = Math.hypot(wantX, wantY);
if (len > speed) {
wantX *= speed / len;
wantY *= speed / len;
}
const dv = accel * dt;
let vx = b.vx + Math.max(-dv, Math.min(dv, wantX - b.vx));
let vy = b.vy + Math.max(-dv, Math.min(dv, wantY - b.vy)) + W.gravity * dt;
const carried = riders(W, b);
const aboard = new Set(carried);
for (const arb of W.arbiters.values()) {
if (arb.a !== b && arb.b !== b) continue;
if (aboard.has(arb.a === b ? arb.b : arb.a)) continue;
const depth = isStatic(arb.a === b ? arb.b : arb.a) ? -1 : -2.5;
for (const c of arb.cs) {
if (c.sep > depth) continue;
const nx = arb.a === b ? c.nx : -c.nx;
const ny = arb.a === b ? c.ny : -c.ny;
const into = vx * nx + vy * ny;
if (into > 0) {
vx -= into * nx;
vy -= into * ny;
}
}
}
for (const r of carried) {
const side = r.friction * GRIP * W.gravity * dt;
r.vx += Math.max(-side, Math.min(side, vx - r.vx));
r.vy += Math.max(0, vy - r.vy);
}
b.vx = vx;
b.vy = vy;
b.w = Math.max(-turn, Math.min(turn, -b.a * turn));
}
function riders(W, base) {
const out = [];
const seen = new Set([base]);
const queue = [base];
while (queue.length) {
const low = queue.shift();
for (const arb of W.arbiters.values()) {
if (arb.a !== low && arb.b !== low) continue;
const other = arb.a === low ? arb.b : arb.a;
if (seen.has(other) || isStatic(other) || !arb.cs.length) continue;
const up = arb.a === low ? arb.cs[0].ny : -arb.cs[0].ny;
if (up < 0.5) continue;
seen.add(other);
out.push(other);
queue.push(other);
}
}
return out;
}
const host = document.querySelector('.kumonosu-stage');
const wrap = host.querySelector('.kumonosu-twr');
const pile = host.querySelector('.kumonosu-twr-pile');
const n = 6,
sl = 0.45,
fr = frictionOf(sl),
H = boxH(n);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const T = world(grav(50), RESTITUTION);
let room, grip = null,
going = null,
raf = 0,
refill = 0;
const rows = new Map();
function row(b) {
const li = document.createElement('li');
li.className = 'kumonosu-twr-row';
Object.assign(li.style, {
width: `${CW}px`,
height: `${CH}px`,
marginLeft: `${-HW}px`
});
const card = document.createElement('div');
card.className = 'kumonosu-twr-card';
const button = document.createElement('button');
button.type = 'button';
button.className = 'kumonosu-twr-check';
button.setAttribute('aria-label', `完了にする:${TODOS[b.id]}`);
button.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m20 6-11 11-5-5"/></svg>';
button.addEventListener('click', () => strike(b.id));
const text = document.createElement('span');
text.className = 'kumonosu-twr-text';
text.textContent = TODOS[b.id];
card.append(button, text);
li.append(card);
pile.append(li);
rows.set(b.id, li);
return li;
}
function draw() {
const bodies = T.bodies.filter(b => b.id < FLOOR);
const alive = new Set(bodies.map(b => b.id));
for (const [id, el] of rows)
if (!alive.has(id) && id !== going) {
el.remove();
rows.delete(id);
}
for (const b of bodies) {
const el = rows.get(b.id) || row(b);
el.style.transform = `translate(${b.x.toFixed(2)}px, ${(-(b.y-HH)).toFixed(2)}px) rotate(${-b.a}rad)`;
el.toggleAttribute('data-hand', !!grip?.live && grip.id === b.id);
}
if (!bodies.length && going === null && !refill) {
refill = setTimeout(() => {
refill = 0;
T.bodies.push(...tower(n, sl, fr, room.floor + Math.min(100, room.top - room.floor - n * CH)));
draw();
go();
}, 700);
}
}
function measure() {
const scale = Math.min(1, host.clientWidth / W, host.clientHeight / H);
wrap.style.setProperty('--kumonosu-scale', scale);
const height = host.clientHeight / scale,
width = host.clientWidth / scale;
room = {
l: -width / 2 + INSET,
r: width / 2 - INSET,
top: (H + height) / 2 - INSET,
floor: (H - height) / 2 + INSET
};
const {
l,
r,
top,
floor
} = room, wide = r - l + 400, tall = top - floor + 400, mid = (l + r) / 2;
for (const [id, x, y, hw, hh] of [
[FLOOR, mid, floor - 100, wide / 2, 100],
[LEFT, l - 100, floor + tall / 2 - 200, 100, tall / 2],
[RIGHT, r + 100, floor + tall / 2 - 200, 100, tall / 2],
[ROOF, mid, top + 100, wide / 2, 100]
]) {
const b = T.bodies.find(b => b.id === id);
if (b) Object.assign(b, {
x,
y,
hw,
hh
});
else T.bodies.push(box(id, x, y, hw, hh, fr, 0));
}
for (const b of T.bodies)
if (b.id < FLOOR) {
b.x = clamp(b.x, room.l + HW, room.r - HW);
b.y = clamp(b.y, room.floor + HH, room.top - HH);
}
T.arbiters.clear();
}
function run() {
if (raf) return;
let prev = 0,
acc = 0,
calm = 0;
function tick(t) {
acc += prev ? Math.min((t - prev) / 1000, MAXSTEPS * DT) : DT;
prev = t;
if (grip && t - grip.t > 34) {
grip.vx *= 0.4;
grip.vy *= 0.4;
}
let steps = 0;
while (acc >= DT && steps < MAXSTEPS) {
if (grip?.live) {
const b = T.bodies.find(b => b.id === grip.id);
if (b) {
const c = Math.cos(b.a),
s = Math.sin(b.a);
steer(T, b, grip.tx - (c * grip.lx - s * grip.ly), grip.ty - (s * grip.lx + c * grip.ly), DT, {
tvx: grip.vx,
tvy: grip.vy
});
}
}
step(T, DT);
acc -= DT;
steps++;
}
const moving = grip?.live || T.bodies.some(b => b.id < FLOOR && (Math.hypot(b.vx, b.vy) > REST_V || Math.abs(b.w) > REST_W));
calm = moving ? 0 : calm + steps;
draw();
if (calm < REST_N) raf = requestAnimationFrame(tick);
else {
raf = 0;
for (const b of T.bodies) {
b.vx = 0;
b.vy = 0;
b.w = 0;
}
}
}
raf = requestAnimationFrame(tick);
}
function go() {
if (!reduced) {
run();
return;
}
cancelAnimationFrame(raf);
raf = 0;
for (let i = 0; i < 2400; i++) step(T, DT);
for (const b of T.bodies) {
b.vx = 0;
b.vy = 0;
b.w = 0;
}
draw();
}
function scrape(gone) {
for (const b of T.bodies)
if (b.id < FLOOR && b.y > gone.y) b.vx += (Math.random() * 2 - 1) * (0.3 + sl) * SCRAPE / Math.max(1, (b.y - gone.y) / CH);
}
function strike(id) {
if (going !== null || grip) return;
const b = T.bodies.find(b => b.id === id);
if (!b) return;
going = id;
const el = rows.get(id);
el.setAttribute('data-out', '');
el.querySelector('button').disabled = true;
const next = [...rows].find(([key]) => key !== id);
if (document.activeElement === el.querySelector('button')) next?.[1].querySelector('button').focus({
preventScroll: true
});
scrape(b);
remove(T, id);
draw();
go();
setTimeout(() => {
el.remove();
rows.delete(id);
going = null;
draw();
}, reduced ? 0 : 400);
}
function local(e) {
const r = wrap.getBoundingClientRect(),
k = r.width / W;
return {
x: (e.clientX - r.left) / k - W / 2,
y: (r.bottom - e.clientY) / k,
k
};
}
host.addEventListener('pointerdown', e => {
if (e.button !== 0 || grip || going !== null || e.target.closest('button')) return;
const p = local(e);
let chosen = null,
near = 12 / p.k;
for (const b of T.bodies) {
if (b.id >= FLOOR) continue;
const dx = p.x - b.x,
dy = p.y - b.y,
c = Math.cos(-b.a),
s = Math.sin(-b.a);
const dist = Math.hypot(Math.max(0, Math.abs(c * dx - s * dy) - HW), Math.max(0, Math.abs(s * dx + c * dy) - HH));
if (dist <= near) {
near = dist;
chosen = b;
}
}
if (!chosen) return;
const b = chosen,
c = Math.cos(-b.a),
s = Math.sin(-b.a),
dx = p.x - b.x,
dy = p.y - b.y;
grip = {
id: b.id,
pointer: e.pointerId,
lx: c * dx - s * dy,
ly: s * dx + c * dy,
ox: e.clientX,
oy: e.clientY,
tx: p.x,
ty: p.y,
vx: 0,
vy: 0,
t: e.timeStamp,
live: false
};
host.setPointerCapture(e.pointerId);
});
host.addEventListener('pointermove', e => {
if (!grip || grip.pointer !== e.pointerId) return;
const g = grip,
p = local(e),
nx = clamp(p.x, room.l, room.r),
ny = clamp(p.y, room.floor, room.top),
dt = Math.max(4, e.timeStamp - g.t) / 1000;
g.vx = g.vx * 0.5 + (nx - g.tx) / dt * 0.5;
g.vy = g.vy * 0.5 + (ny - g.ty) / dt * 0.5;
g.t = e.timeStamp;
g.tx = nx;
g.ty = ny;
if (!g.live) {
if (Math.hypot(e.clientX - g.ox, e.clientY - g.oy) < GRAB) return;
g.live = true;
const b = T.bodies.find(b => b.id === g.id),
m = massOf(HW, HH);
b.invM = m.invM * 0.25;
b.invI = m.invI * 0.25;
b.a -= Math.round(b.a / (2 * Math.PI)) * 2 * Math.PI;
T.held = b.id;
scrape(b);
run();
}
});
function drop(e) {
if (!grip || grip.pointer !== e.pointerId) return;
const g = grip;
grip = null;
T.held = null;
if (host.hasPointerCapture(e.pointerId)) host.releasePointerCapture(e.pointerId);
if (g.live) {
const b = T.bodies.find(b => b.id === g.id);
Object.assign(b, massOf(HW, HH));
release(b);
b.vx = clamp(b.vx, -VMAX, VMAX);
b.vy = clamp(b.vy, -VMAX, VMAX);
go();
}
draw();
}
for (const name of ['pointerup', 'pointercancel', 'lostpointercapture']) host.addEventListener(name, drop);
measure();
T.bodies.push(...tower(n, sl, fr, room.floor));
draw();
new ResizeObserver(() => {
measure();
go();
}).observe(host);
})();
このデモは、外部ライブラリを一切使わず、バニラJavaScriptで2D剛体物理シミュレーションを実装し、タスクリストのカードを物理的に積み上げるUIです。
主な機能は以下のとおりです。
prefers-reduced-motion対応: モーション軽減設定が有効な場合、アニメーションを即座に最終状態まで進め、トランジション時間も1msに短縮します。aria-labelが設定されており、ステージ全体にもaria-labelが付与されています。フォーカスの移動処理も行っています。TODOS配列の文字列を書き換えるだけで、表示されるタスク名を変更できます。n変数(デフォルト6)とTODOS配列の要素数を合わせて変更すると、カードの枚数を変えられます。最大8枚(MAX)まで対応しています。grav関数の値やworldに渡す重力値を変更すると、カードの落下速度や安定性が変わります。sl(スリッパリー値、デフォルト0.45)を変更すると、カード同士の滑りやすさが変わります。大きくすると滑りやすく、小さくすると安定します。CW(幅206px)とCH(高さ46px)を変更すると、カードの大きさが変わります。LEAN配列の値を変更すると、初期積み上げ時のカードの横ずれ量が変わります。0にすると綺麗に積み上がります。--card、--fill-on、--inkなどのカスタムプロパティを変更すると、全体の配色を変えられます。.kumonosu-twr-cardのborder-radius(デフォルト11px)を変更するとカードの形状が変わります。setPointerCaptureを使用しているため、ドラッグ中にポインターがステージ外に出ても操作が継続します。pointercancelとlostpointercaptureにも対応しています。Math.min(1, clientWidth/W, clientHeight/H)で計算され、CSSのtransform: scale()で適用されます。物理空間のサイズは固定で、表示のみ縮小されます。going !== null)は他のチェック操作やドラッグが無効化されます。連打すると操作を受け付けないタイミングがあります。