Physics Keyword Pit (falling, tumbling tags)
A block where skill keywords drop from above, tumble and pile up at the bottom of a box. Each tag is a readable button, and tags with larger counts are bigger and heavier. Drag a tag to grab and throw it, press Shake to bounce the pile apart, and Drop again to rain them down once more in order of count. The rigid-body physics is hand-written with no dependencies: contacts between rotated rectangles come from a separating-axis test plus edge clipping, and normal and friction impulses are solved iteratively. Impulses are carried over per contact from the previous step, so a pile of nearly twenty tags rests without jitter. Since a rectangle looks the same after a 180° turn, only the displayed angle is folded into ±90°, so no tag ever reads upside down. The simulation sleeps once the pile settles and also stops off-screen and on hidden tabs. From the keyboard, focus a tag and use Left/Right to push it, Up to toss it and Down to press it down. Under prefers-reduced-motion nothing is simulated: a still stack ordered by count is drawn from the bottom up, the same keys reorder it (back, forward, to the top, to the bottom), and the result is announced via aria-live. A ledger below shows how many tags are moving, how many rest on the floor, and which tag is on top. No rounded corners, English kickers, trailing arrow links or entrance animations.
- Added:
- 2026-10-10
- Dependencies:
- None
- tags
- #block #physics #rigid-body #tags #keywords #drag #throw #interactive #data-visualization #keyboard #accessibility #no-image #no-dependency
Preview
得意分野 18語
手がけてきた仕事を、札にして落としました
札の大きさは実績の件数です。つかんで投げても、ゆすって崩しても、どの札も文字が読める向きのまま積もります。
ドラッグで投げられます。キーボードは札を選んで ← → で押す・↑ で放り上げる・↓ で押し込む
- 札の数
- 18枚
- 動いている
- 0枚
- 床に着いた
- 0枚
- てっぺんの札
- —
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
/**
* 物理で積もるキーワード(BLK-94)
* 粒で積もる既存の背景(sand-deposit-backdrop)は1粒が読めない装飾なので、本ブロックは1枚ずつ読める札を
* 剛体として積み、つかむ・投げる・ゆするを主役にした。件数は札の大きさ(=質量)で見せる。
* 剛体計算は依存なしの自作: 箱どうしは分離軸で最も浅い面を選び、相手の辺を側面で切り取って接触点を2つまで出す。
* 接触は「法線の衝撃量を0以上・摩擦は法線×係数以内」に抑える逐次インパルスで10回反復し、前ステップの衝撃量を
* 特徴番号で引き継いで(warm start)積み山を静かに保つ。長方形は180°回しても同じ形なので、表示だけ角度を
* ±90°に畳んで文字が逆さに見えないようにしている。
*/
type Keyword = { id: string; label: string; count: number };
const KEYWORDS: Keyword[] = [
{ id: "corp", label: "コーポレートサイト", count: 46 },
{ id: "lp", label: "LP制作", count: 38 },
{ id: "speed", label: "表示速度の改善", count: 31 },
{ id: "recruit", label: "採用サイト", count: 27 },
{ id: "form", label: "予約フォーム", count: 24 },
{ id: "seo", label: "SEO設計", count: 22 },
{ id: "cms", label: "CMS導入", count: 19 },
{ id: "a11y", label: "アクセシビリティ", count: 17 },
{ id: "i18n", label: "多言語対応", count: 15 },
{ id: "ops", label: "運用保守", count: 14 },
{ id: "copy", label: "原稿づくり", count: 12 },
{ id: "analytics", label: "アクセス解析", count: 11 },
{ id: "ec", label: "EC構築", count: 9 },
{ id: "schema", label: "構造化データ", count: 8 },
{ id: "photo", label: "写真撮影", count: 7 },
{ id: "map", label: "地図・アクセス", count: 6 },
{ id: "contact", label: "問い合わせ導線", count: 5 },
{ id: "print", label: "名刺・チラシ", count: 4 },
];
/** 件数の段で札の大きさを変える(大きい札ほど重い) */
const TIER = (count: number) =>
count >= 30
? "px-3 py-1.5 text-[14px] @2xl:px-4 @2xl:py-2 @2xl:text-[18px]"
: count >= 15
? "px-2.5 py-1 text-[12.5px] @2xl:px-3 @2xl:py-1.5 @2xl:text-[15px]"
: "px-2 py-1 text-[11px] @2xl:px-2.5 @2xl:text-[12.5px]";
const STEP = 1 / 60; // 固定刻み(可変にすると積み山が震える)
const ITER = 10; // 接触の反復回数
const GRAVITY = 1900; // px/s²
const FRICTION = 0.55;
const SLOP = 0.6; // 許す食い込み(px)。0にすると接触が切れたり付いたりして震える
const BIAS = 0.2; // 食い込みを1ステップで戻す割合
const VMAX = 2600; // 投げの上限(px/s)。壁のすり抜けを防ぐ
const WALL = 400; // 壁の厚み。薄いと速い札が抜ける
const REST_V = 7; // これ未満の速さが続いたら眠らせる
const REST_STEPS = 50;
type Body = {
x: number;
y: number;
a: number;
vx: number;
vy: number;
w: number;
hw: number;
hh: number;
im: number;
ii: number;
};
type Contact = {
px: number;
py: number;
nx: number;
ny: number;
sep: number;
key: number;
pn: number;
pt: number;
mn: number;
mt: number;
bias: number;
};
type ClipV = { x: number; y: number; i1: number; i2: number; o1: number; o2: number };
const E1 = 1;
const E2 = 2;
const E3 = 3;
const E4 = 4;
const keyOf = (v: ClipV) => v.i1 | (v.i2 << 4) | (v.o1 << 8) | (v.o2 << 12);
function clip(vin: ClipV[], nx: number, ny: number, offset: number, edge: number): ClipV[] {
const out: ClipV[] = [];
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: ClipV = {
...(d0 > 0 ? vin[0] : vin[1]),
x: vin[0].x + t * (vin[1].x - vin[0].x),
y: vin[0].y + t * (vin[1].y - vin[0].y),
};
if (d0 > 0) {
v.i1 = edge;
v.i2 = 0;
} else {
v.o1 = edge;
v.o2 = 0;
}
out.push(v);
}
return out;
}
/** 参照面の法線に対して最も向き合う相手の辺(2頂点)を世界座標で返す */
function incidentEdge(b: Body, nx: number, ny: number): ClipV[] {
const c = Math.cos(b.a);
const s = Math.sin(b.a);
// 法線を相手の座標系へ入れて裏返す
const lx = -(c * nx + s * ny);
const ly = -(-s * nx + c * ny);
const { hw, hh } = b;
let v: ClipV[];
if (Math.abs(lx) > Math.abs(ly)) {
v =
lx > 0
? [
{ x: hw, y: -hh, i1: 0, i2: E3, o1: 0, o2: E4 },
{ x: hw, y: hh, i1: 0, i2: E4, o1: 0, o2: E1 },
]
: [
{ x: -hw, y: hh, i1: 0, i2: E1, o1: 0, o2: E2 },
{ x: -hw, y: -hh, i1: 0, i2: E2, o1: 0, o2: E3 },
];
} else {
v =
ly > 0
? [
{ x: hw, y: hh, i1: 0, i2: E4, o1: 0, o2: E1 },
{ x: -hw, y: hh, i1: 0, i2: E1, o1: 0, o2: E2 },
]
: [
{ x: -hw, y: -hh, i1: 0, i2: E2, o1: 0, o2: E3 },
{ x: hw, y: -hh, i1: 0, i2: E3, o1: 0, o2: E4 },
];
}
for (const p of v) {
const x = b.x + c * p.x - s * p.y;
const y = b.y + s * p.x + c * p.y;
p.x = x;
p.y = y;
}
return v;
}
/** 箱どうしの接触(0〜2点)。法線は A→B 向き */
function collide(A: Body, B: Body): Omit<Contact, "pn" | "pt" | "mn" | "mt" | "bias">[] {
const cA = Math.cos(A.a);
const sA = Math.sin(A.a);
const cB = Math.cos(B.a);
const sB = Math.sin(B.a);
const dx = B.x - A.x;
const dy = B.y - A.y;
const dAx = cA * dx + sA * dy;
const dAy = -sA * dx + cA * dy;
const dBx = cB * dx + sB * dy;
const dBy = -sB * dx + cB * dy;
// A から見た B の回転(B−A)の絶対値行列
const ac = Math.abs(Math.cos(B.a - A.a));
const as = Math.abs(Math.sin(B.a - A.a));
const faceAx = Math.abs(dAx) - A.hw - (ac * B.hw + as * B.hh);
const faceAy = Math.abs(dAy) - A.hh - (as * B.hw + ac * B.hh);
if (faceAx > 0 || faceAy > 0) return [];
const faceBx = Math.abs(dBx) - (ac * A.hw + as * A.hh) - B.hw;
const faceBy = Math.abs(dBy) - (as * A.hw + ac * A.hh) - B.hh;
if (faceBx > 0 || faceBy > 0) return [];
// 同点なら A の面を優先する(軸が毎ステップ入れ替わると接触番号が変わり warm start が効かない)
const REL = 0.95;
const ABS = 0.01;
let axis = 0;
let sep = faceAx;
let nx = dAx > 0 ? cA : -cA;
let ny = dAx > 0 ? sA : -sA;
if (faceAy > REL * sep + ABS * A.hh) {
axis = 1;
sep = faceAy;
nx = dAy > 0 ? -sA : sA;
ny = dAy > 0 ? cA : -cA;
}
if (faceBx > REL * sep + ABS * B.hw) {
axis = 2;
sep = faceBx;
nx = dBx > 0 ? cB : -cB;
ny = dBx > 0 ? sB : -sB;
}
if (faceBy > REL * sep + ABS * B.hh) {
axis = 3;
sep = faceBy;
nx = dBy > 0 ? -sB : sB;
ny = dBy > 0 ? cB : -cB;
}
let fx: number, fy: number, front: number, sx: number, sy: number, negSide: number, posSide: number;
let negEdge: number, posEdge: number;
let inc: ClipV[];
if (axis === 0 || axis === 1) {
fx = nx;
fy = ny;
const ref = A;
const [h, side] = axis === 0 ? [A.hw, A.hh] : [A.hh, A.hw];
front = ref.x * fx + ref.y * fy + h;
[sx, sy] = axis === 0 ? [-sA, cA] : [cA, sA];
const sd = ref.x * sx + ref.y * sy;
negSide = -sd + side;
posSide = sd + side;
[negEdge, posEdge] = axis === 0 ? [E3, E1] : [E2, E4];
inc = incidentEdge(B, fx, fy);
} else {
fx = -nx;
fy = -ny;
const ref = B;
const [h, side] = axis === 2 ? [B.hw, B.hh] : [B.hh, B.hw];
front = ref.x * fx + ref.y * fy + h;
[sx, sy] = axis === 2 ? [-sB, cB] : [cB, sB];
const sd = ref.x * sx + ref.y * sy;
negSide = -sd + side;
posSide = sd + side;
[negEdge, posEdge] = axis === 2 ? [E3, E1] : [E2, E4];
inc = incidentEdge(A, fx, fy);
}
const c1 = clip(inc, -sx, -sy, negSide, negEdge);
if (c1.length < 2) return [];
const c2 = clip(c1, sx, sy, posSide, posEdge);
if (c2.length < 2) return [];
const out: Omit<Contact, "pn" | "pt" | "mn" | "mt" | "bias">[] = [];
for (const v of c2) {
const s = fx * v.x + fy * v.y - front;
if (s > 0) continue;
// B の面が基準のときは特徴番号の向きをそろえる
const k = axis >= 2 ? keyOf({ ...v, i1: v.i2, i2: v.i1, o1: v.o2, o2: v.o1 }) : keyOf(v);
out.push({ px: v.x - s * fx, py: v.y - s * fy, nx, ny, sep: s, key: k });
}
return out;
}
/** 回転した箱の外接矩形の半幅・半高 */
const extent = (b: Body) => {
const c = Math.abs(Math.cos(b.a));
const s = Math.abs(Math.sin(b.a));
return { ex: c * b.hw + s * b.hh, ey: s * b.hw + c * b.hh };
};
/** 長方形は180°回しても同じ形。表示だけ ±90° に畳んで文字を逆さにしない */
const fold = (a: number) => a - Math.PI * Math.round(a / Math.PI);
type Stats = { moving: number; top: string; floor: number };
type Api = {
shake: () => void;
redrop: () => void;
nudge: (i: number, key: "left" | "right" | "up" | "down") => void;
grab: (i: number, clientX: number, clientY: number) => void;
drag: (clientX: number, clientY: number) => void;
release: () => void;
};
export default function PhysicsKeywordPit() {
const uid = useId();
const hintId = `${uid}-hint`;
const headingId = `${uid}-h`;
const [still, setStill] = useState(false);
const [ready, setReady] = useState(false);
const [grabbed, setGrabbed] = useState(-1);
const [stats, setStats] = useState<Stats>({ moving: 0, top: "—", floor: 0 });
const [announce, setAnnounce] = useState("");
const stageRef = useRef<HTMLDivElement>(null);
const shakeRef = useRef<HTMLDivElement>(null);
const tagRefs = useRef<(HTMLButtonElement | null)[]>([]);
const apiRef = useRef<Api | null>(null);
const say = (msg: string) => {
setAnnounce("");
requestAnimationFrame(() => setAnnounce(msg));
};
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const n = KEYWORDS.length;
let W = 1;
let H = 1;
let raf = 0;
let last = 0;
let acc = 0;
let onScreen = true;
let visible = document.visibilityState !== "hidden";
let restFor = 0;
let asleep = false;
let sinceReport = 0;
let seen = "";
let laidOut = false;
// 最初は積もった後の並びを置いておき、画面に入ってフレームが回り始めたときに初めて落とす。
// 画面外のままのときや静止画の撮影でも箱が空にならない
let dropped = false;
let prevFrame = -1;
let smooth = 0;
let frames = 0;
// 0..n-1 が札、その後ろが床・左右の壁・天井(動かない箱)
const bodies: Body[] = KEYWORDS.map(() => ({ x: 0, y: 0, a: 0, vx: 0, vy: 0, w: 0, hw: 20, hh: 10, im: 1, ii: 1 }));
const walls: Body[] = Array.from({ length: 4 }, () => ({ x: 0, y: 0, a: 0, vx: 0, vy: 0, w: 0, hw: 1, hh: 1, im: 0, ii: 0 }));
const all = [...bodies, ...walls];
const arbiters = new Map<number, Contact[]>();
// つかんでいる札(局所座標のつかみ点と、追いかける目標)
const hold = { i: -1, lx: 0, ly: 0, tx: 0, ty: 0 };
// 静止時の並び(先頭ほど下段)。キーボード操作はこの並び替えになる
const order = KEYWORDS.map((_, i) => i);
let shuffles = 0;
const measure = () => {
tagRefs.current.forEach((el, i) => {
if (!el) return;
const b = bodies[i];
// offsetWidth はレイアウト寸法(一覧カードの縮小 scale に影響されない)
b.hw = el.offsetWidth / 2;
b.hh = el.offsetHeight / 2;
const m = (4 * b.hw * b.hh) / 1000;
b.im = 1 / m;
b.ii = 1 / ((m * (4 * b.hw * b.hw + 4 * b.hh * b.hh)) / 12);
});
};
const placeWalls = () => {
const [floor, left, right, ceil] = walls;
Object.assign(floor, { x: W / 2, y: H + WALL / 2, hw: W / 2 + WALL, hh: WALL / 2 });
Object.assign(left, { x: -WALL / 2, y: -H, hw: WALL / 2, hh: H * 2 + WALL });
Object.assign(right, { x: W + WALL / 2, y: -H, hw: WALL / 2, hh: H * 2 + WALL });
Object.assign(ceil, { x: W / 2, y: -H * 3 - WALL / 2, hw: W / 2 + WALL, hh: WALL / 2 });
};
const paint = () => {
for (let i = 0; i < n; i++) {
const el = tagRefs.current[i];
const b = bodies[i];
if (el) el.style.transform = `translate(${(b.x - b.hw).toFixed(1)}px, ${(b.y - b.hh).toFixed(1)}px) rotate(${fold(b.a).toFixed(4)}rad)`;
}
};
const report = (force = false) => {
sinceReport++;
if (!force && sinceReport < 12) return;
sinceReport = 0;
let moving = 0;
let floor = 0;
let topI = -1;
let topY = Infinity;
for (let i = 0; i < n; i++) {
const b = bodies[i];
const { ey } = extent(b);
if (Math.hypot(b.vx, b.vy) > 30 || Math.abs(b.w) > 0.6) moving++;
if (b.y + ey >= H - 6) floor++;
// 画面より上にある(まだ落ちてくる途中の)札は数えない
if (b.y - ey >= -2 && b.y - ey < topY) {
topY = b.y - ey;
topI = i;
}
}
const top = topI >= 0 ? KEYWORDS[topI].label : "—";
const sig = `${moving}|${top}|${floor}`;
if (sig === seen) return;
seen = sig;
setStats({ moving, top, floor });
};
const step = () => {
// 接触の更新(前ステップの衝撃量を同じ特徴番号の接触へ引き継ぐ)
const ext = all.map(extent);
for (let i = 0; i < all.length; i++) {
for (let j = i + 1; j < all.length; j++) {
const A = all[i];
const B = all[j];
const key = i * 64 + j;
if (
(A.im === 0 && B.im === 0) ||
Math.abs(A.x - B.x) > ext[i].ex + ext[j].ex ||
Math.abs(A.y - B.y) > ext[i].ey + ext[j].ey
) {
arbiters.delete(key);
continue;
}
const fresh = collide(A, B);
if (!fresh.length) {
arbiters.delete(key);
continue;
}
const old = arbiters.get(key);
arbiters.set(
key,
fresh.map((c) => {
const prev = old?.find((o) => o.key === c.key);
return { ...c, pn: prev?.pn ?? 0, pt: prev?.pt ?? 0, mn: 0, mt: 0, bias: 0 };
}),
);
}
}
// 重力
for (const b of bodies) {
if (b.im === 0) continue;
b.vy += GRAVITY * STEP;
}
const pairs = [...arbiters.entries()].map(([k, cs]) => ({ A: all[Math.floor(k / 64)], B: all[k % 64], cs }));
// 前処理と warm start
for (const { A, B, cs } of pairs) {
for (const c of cs) {
const r1x = c.px - A.x;
const r1y = c.py - A.y;
const r2x = c.px - B.x;
const r2y = c.py - B.y;
const rn1 = r1x * c.nx + r1y * c.ny;
const rn2 = r2x * c.nx + r2y * c.ny;
c.mn = 1 / (A.im + B.im + A.ii * (r1x * r1x + r1y * r1y - rn1 * rn1) + B.ii * (r2x * r2x + r2y * r2y - rn2 * rn2));
const tx = c.ny;
const ty = -c.nx;
const rt1 = r1x * tx + r1y * ty;
const rt2 = r2x * tx + r2y * ty;
c.mt = 1 / (A.im + B.im + A.ii * (r1x * r1x + r1y * r1y - rt1 * rt1) + B.ii * (r2x * r2x + r2y * r2y - rt2 * rt2));
c.bias = (-BIAS / STEP) * Math.min(0, c.sep + SLOP);
const Px = c.pn * c.nx + c.pt * tx;
const Py = c.pn * c.ny + c.pt * ty;
A.vx -= A.im * Px;
A.vy -= A.im * Py;
A.w -= A.ii * (r1x * Py - r1y * Px);
B.vx += B.im * Px;
B.vy += B.im * Py;
B.w += B.ii * (r2x * Py - r2y * Px);
}
}
// つかみ点を目標へ寄せる拘束(位置の誤差を速度目標に変える)
const H0 = hold.i >= 0 ? bodies[hold.i] : null;
let hrx = 0;
let hry = 0;
let hk: [number, number, number, number] = [0, 0, 0, 0];
let htx = 0;
let hty = 0;
if (H0) {
const c = Math.cos(H0.a);
const s = Math.sin(H0.a);
hrx = c * hold.lx - s * hold.ly;
hry = s * hold.lx + c * hold.ly;
const k11 = H0.im + H0.ii * hry * hry;
const k12 = -H0.ii * hrx * hry;
const k22 = H0.im + H0.ii * hrx * hrx;
const det = k11 * k22 - k12 * k12 || 1;
hk = [k22 / det, -k12 / det, -k12 / det, k11 / det];
htx = (-(H0.x + hrx - hold.tx) * 0.35) / STEP;
hty = (-(H0.y + hry - hold.ty) * 0.35) / STEP;
H0.w *= 0.9;
}
for (let it = 0; it < ITER; it++) {
if (H0) {
const vpx = H0.vx - H0.w * hry;
const vpy = H0.vy + H0.w * hrx;
const dvx = htx - vpx;
const dvy = hty - vpy;
let jx = hk[0] * dvx + hk[1] * dvy;
let jy = hk[2] * dvx + hk[3] * dvy;
// 1ステップに出せる力を抑える(重い札ほど追従が遅れる)
const lim = (1 / H0.im) * 9000 * STEP;
const mag = Math.hypot(jx, jy);
if (mag > lim) {
jx *= lim / mag;
jy *= lim / mag;
}
H0.vx += H0.im * jx;
H0.vy += H0.im * jy;
H0.w += H0.ii * (hrx * jy - hry * jx);
}
for (const { A, B, cs } of pairs) {
for (const c of cs) {
const r1x = c.px - A.x;
const r1y = c.py - A.y;
const r2x = c.px - B.x;
const r2y = c.py - 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;
let Py = dPn * c.ny;
A.vx -= A.im * Px;
A.vy -= A.im * Py;
A.w -= A.ii * (r1x * Py - r1y * Px);
B.vx += B.im * Px;
B.vy += B.im * Py;
B.w += B.ii * (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;
const ty = -c.nx;
const vt = dvx * tx + dvy * ty;
let dPt = c.mt * -vt;
const maxPt = FRICTION * c.pn;
const pt0 = c.pt;
c.pt = Math.max(-maxPt, Math.min(maxPt, pt0 + dPt));
dPt = c.pt - pt0;
Px = dPt * tx;
Py = dPt * ty;
A.vx -= A.im * Px;
A.vy -= A.im * Py;
A.w -= A.ii * (r1x * Py - r1y * Px);
B.vx += B.im * Px;
B.vy += B.im * Py;
B.w += B.ii * (r2x * Py - r2y * Px);
}
}
}
let fastest = 0;
for (let i = 0; i < n; i++) {
const b = bodies[i];
const sp = Math.hypot(b.vx, b.vy);
if (sp > VMAX) {
b.vx *= VMAX / sp;
b.vy *= VMAX / sp;
}
b.w = Math.max(-18, Math.min(18, b.w));
b.x += b.vx * STEP;
b.y += b.vy * STEP;
b.a += b.w * STEP;
// 万一壁を抜けたら上から落とし直す
if (b.x < -WALL || b.x > W + WALL || b.y > H + WALL) {
b.x = W * (0.2 + 0.6 * Math.random());
b.y = -80;
b.vx = 0;
b.vy = 0;
b.w = 0;
}
fastest = Math.max(fastest, sp + Math.abs(b.w) * b.hw);
}
if (hold.i < 0 && fastest < REST_V) restFor++;
else restFor = 0;
};
/** reduced-motion 用の静止した並び。下段から左詰めで積み、行ごとに中央へ寄せる */
const layoutStill = () => {
const GAP = 6;
const PAD = 10;
const rows: number[][] = [[]];
let x = PAD;
for (const i of order) {
const wdt = bodies[i].hw * 2;
if (x + wdt > W - PAD && rows[rows.length - 1].length) {
rows.push([]);
x = PAD;
}
rows[rows.length - 1].push(i);
x += wdt + GAP;
}
let bottom = H - 4;
for (const row of rows) {
const rowW = row.reduce((s, i) => s + bodies[i].hw * 2, 0) + GAP * (row.length - 1);
let cx = (W - rowW) / 2;
const rowH = Math.max(...row.map((i) => bodies[i].hh * 2));
for (const i of row) {
const b = bodies[i];
b.x = cx + b.hw;
b.y = bottom - b.hh;
// 決定的な小さい傾き(積んだ感じは残し、文字は読める範囲)
b.a = ((((i + 3) * 2654435761) >>> 0) % 7 - 3) * 0.007;
b.vx = b.vy = b.w = 0;
cx += b.hw * 2 + GAP;
}
bottom -= rowH + GAP;
}
paint();
report(true);
};
const drop = () => {
arbiters.clear();
// 件数の多い札から順に落とす(大きい札が下に溜まりやすい)
const sorted = KEYWORDS.map((_, i) => i).sort((p, q) => KEYWORDS[q].count - KEYWORDS[p].count);
sorted.forEach((i, k) => {
const b = bodies[i];
b.x = Math.min(W - b.hw - 4, Math.max(b.hw + 4, W * (0.12 + 0.76 * Math.random())));
b.y = -40 - k * 46;
b.a = (Math.random() - 0.5) * 0.5;
b.vx = (Math.random() - 0.5) * 60;
b.vy = 0;
b.w = (Math.random() - 0.5) * 1.5;
});
restFor = 0;
};
const frame = (time: number) => {
// 落とすのはフレームがまともな間隔(50ms未満)で6回続いてから(通常は約0.1秒)。静止画の撮影(仮想時間)では
// rAF が数百msおきに数回しか来ず、落とした札が箱の上で止まって空の箱が写る。それまでは積もった並びのまま残す。
// 負荷で間隔が50msを超え続ける端末でも、20フレーム来たら落とす(撮影の数回には届かない)
if (!dropped) {
frames++;
if (prevFrame >= 0 && time - prevFrame > 0 && time - prevFrame < 50) smooth++;
prevFrame = time;
if (smooth < 6 && frames < 20) {
raf = requestAnimationFrame(frame);
return;
}
dropped = true;
drop();
last = 0;
}
if (!last) last = time;
acc += Math.min(0.05, (time - last) / 1000);
last = time;
let steps = 0;
while (acc >= STEP && steps < 3) {
step();
acc -= STEP;
steps++;
}
if (steps === 3) acc = 0;
paint();
report();
if (restFor > REST_STEPS) {
// 積み山が落ち着いたら止める(操作で起こす)
asleep = true;
for (const b of bodies) b.vx = b.vy = b.w = 0;
raf = 0;
report(true);
return;
}
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const start = () => {
if (raf || mql.matches || !onScreen || !visible || asleep || !laidOut) return;
last = 0;
acc = 0;
raf = requestAnimationFrame(frame);
};
const wake = () => {
asleep = false;
restFor = 0;
start();
};
// 操作されたら落下の開始を待たず、積もった並びのまま物理を回す(待つ間は操作が効かない)
const engage = () => {
dropped = true;
wake();
};
const toLocal = (clientX: number, clientY: number) => {
const rect = stage.getBoundingClientRect();
// 一覧カードでは親が縮小されているので、見た目の幅÷レイアウト幅で戻す
const k = rect.width / (stage.offsetWidth || 1) || 1;
return { x: (clientX - rect.left) / k, y: (clientY - rect.top) / k };
};
apiRef.current = {
shake: () => {
shakeRef.current?.animate(
[
{ transform: "translateX(0)" },
{ transform: "translateX(-7px)" },
{ transform: "translateX(6px)" },
{ transform: "translateX(-4px)" },
{ transform: "translateX(0)" },
],
{ duration: 360, easing: "ease-out" },
);
for (const b of bodies) {
b.vx += (Math.random() - 0.5) * 700;
b.vy -= 380 + Math.random() * 520;
b.w += (Math.random() - 0.5) * 7;
}
engage();
},
redrop: () => {
dropped = true;
drop();
engage();
},
nudge: (i, key) => {
const b = bodies[i];
if (key === "left") b.vx -= 520;
if (key === "right") b.vx += 520;
if (key === "up") {
b.vy = -1050;
b.w += (Math.random() - 0.5) * 9;
// 埋もれた札は上に載った札に押さえられて飛ばない。真上に重なる札もいっしょに持ち上げる
for (const o of bodies) {
if (o === b || o.y >= b.y || Math.abs(o.x - b.x) > b.hw + extent(o).ex) continue;
o.vy = Math.min(o.vy, -900);
}
}
if (key === "down") b.vy += 700;
engage();
},
grab: (i, clientX, clientY) => {
const p = toLocal(clientX, clientY);
const b = bodies[i];
const dx = p.x - b.x;
const dy = p.y - b.y;
const c = Math.cos(b.a);
const s = Math.sin(b.a);
Object.assign(hold, { i, lx: c * dx + s * dy, ly: -s * dx + c * dy, tx: p.x, ty: p.y });
engage();
},
drag: (clientX, clientY) => {
if (hold.i < 0) return;
const p = toLocal(clientX, clientY);
hold.tx = Math.max(0, Math.min(W, p.x));
hold.ty = Math.max(-H, Math.min(H, p.y));
},
release: () => {
hold.i = -1;
engage();
},
};
const stillApi = {
shake: () => {
// 静止時は「崩れた後」の並びへ置き換える(動きは出さずに操作を生かす)
shuffles++;
for (let k = order.length - 1; k > 0; k--) {
const j = (((k + shuffles) * 2654435761) >>> 0) % (k + 1);
[order[k], order[j]] = [order[j], order[k]];
}
layoutStill();
},
redrop: () => {
order.sort((p, q) => KEYWORDS[q].count - KEYWORDS[p].count);
layoutStill();
},
nudge: (i: number, key: "left" | "right" | "up" | "down") => {
const at = order.indexOf(i);
order.splice(at, 1);
const to = key === "left" ? Math.max(0, at - 1) : key === "right" ? Math.min(order.length, at + 1) : key === "up" ? order.length : 0;
order.splice(to, 0, i);
layoutStill();
},
};
// 静止時は同じ入口を並び替えへ差し替える
const live = apiRef.current;
const route = () => {
apiRef.current = mql.matches
? { ...live, ...stillApi, grab: () => {}, drag: () => {}, release: () => {} }
: live;
};
const ro = new ResizeObserver((entries) => {
const box = entries[0].contentRect;
const nw = Math.max(1, Math.round(box.width));
const nh = Math.max(1, Math.round(box.height));
if (nw === W && nh === H && laidOut) return;
const first = !laidOut;
W = nw;
H = nh;
measure();
placeWalls();
laidOut = true;
if (mql.matches) {
layoutStill();
} else if (first) {
layoutStill();
} else {
// 幅が縮んだら枠の内へ寄せてから起こす
for (const b of bodies) b.x = Math.min(W - b.hw, Math.max(b.hw, b.x));
arbiters.clear();
wake();
}
setReady(true);
start();
});
ro.observe(stage);
const io = new IntersectionObserver(
(entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) start();
else stop();
},
{ rootMargin: "120px" },
);
io.observe(stage);
const onVisibility = () => {
visible = document.visibilityState !== "hidden";
if (visible) start();
else stop();
};
const onMotion = () => {
setStill(mql.matches);
route();
if (mql.matches) {
stop();
hold.i = -1;
if (laidOut) layoutStill();
} else {
dropped = true;
drop();
wake();
}
};
document.addEventListener("visibilitychange", onVisibility);
mql.addEventListener("change", onMotion);
setStill(mql.matches);
route();
return () => {
stop();
apiRef.current = null;
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
mql.removeEventListener("change", onMotion);
};
}, []);
const onTagKey = (i: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
const map = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down" } as const;
const key = map[e.key as keyof typeof map];
if (!key) return;
e.preventDefault();
apiRef.current?.nudge(i, key);
const label = KEYWORDS[i].label;
if (still) {
const where = { left: "ひとつ前へ", right: "ひとつ後ろへ", up: "いちばん上へ", down: "いちばん下へ" }[key];
say(`「${label}」を${where}移しました`);
}
};
const onDown = (i: number) => (e: ReactPointerEvent<HTMLButtonElement>) => {
if (still || e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
apiRef.current?.grab(i, e.clientX, e.clientY);
setGrabbed(i);
};
const onMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
if (grabbed < 0) return;
apiRef.current?.drag(e.clientX, e.clientY);
};
const onUp = () => {
if (grabbed < 0) return;
apiRef.current?.release();
setGrabbed(-1);
};
const RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";
const readouts = [
{ label: "札の数", value: `${KEYWORDS.length}`, unit: "枚" },
{ label: "動いている", value: `${stats.moving}`, unit: "枚" },
{ label: "床に着いた", value: `${stats.floor}`, unit: "枚" },
{ label: "てっぺんの札", value: stats.top, unit: "" },
];
return (
<section aria-labelledby={headingId} className="@container w-full max-w-4xl border border-gray-200 bg-white text-gray-900">
{/* 広い枠では見出しを箱の空いた上部へ重ねる(札が積もるのは下半分なので、主役を最初の一画面に収める) */}
<div className="relative bg-[linear-gradient(180deg,#ffffff_0%,#ffffff_55%,#faf7f2_100%)]">
<div className="grid gap-x-10 gap-y-4 border-b border-gray-200 px-5 py-6 @2xl:pointer-events-none @2xl:absolute @2xl:inset-x-0 @2xl:top-0 @2xl:grid-cols-[minmax(0,1fr)_17rem] @2xl:border-b-0 @2xl:px-8 @2xl:py-8">
<div>
<p className="text-[12px] font-bold tracking-[0.08em] text-amber-700">得意分野 {KEYWORDS.length}語</p>
<h2 id={headingId} className="mt-2 text-[clamp(1.35rem,4.2vw,2rem)] leading-[1.4] font-bold tracking-tight">
<span className="inline-block">手がけてきた仕事を、</span>
<span className="inline-block">札にして落としました</span>
</h2>
</div>
<p className="text-[13px] leading-[1.9] text-gray-600 @2xl:self-end">
札の大きさは実績の件数です。つかんで投げても、ゆすって崩しても、どの札も文字が読める向きのまま積もります。
</p>
</div>
<div ref={shakeRef}>
<div ref={stageRef} className="relative h-[24rem] touch-pan-y overflow-hidden @2xl:h-[30rem]">
{/* 床の罫線(飾り) */}
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gray-300" />
{/* 配置が済むまでは opacity で隠す(invisible だと文字の和文フォントが読み込まれず、静止画の撮影で札が空白になる) */}
<ul aria-label="得意分野の札" className={ready ? "" : "opacity-0"}>
{KEYWORDS.map((k, i) => (
<li key={k.id} className="contents">
<button
ref={(el) => {
tagRefs.current[i] = el;
}}
type="button"
aria-describedby={hintId}
aria-label={`${k.label}(${k.count}件)`}
onKeyDown={onTagKey(i)}
onPointerDown={onDown(i)}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onLostPointerCapture={onUp}
className={`absolute top-0 left-0 flex origin-center items-baseline gap-1.5 border font-bold whitespace-nowrap select-none ${TIER(k.count)} ${
still ? "cursor-default" : "cursor-grab touch-none active:cursor-grabbing"
} ${grabbed === i ? "z-10 border-amber-600 bg-amber-50" : "border-gray-900 bg-white"} ${RING}`}
>
<span>{k.label}</span>
<span aria-hidden="true" className="font-mono text-[0.78em] text-amber-700 tabular-nums">
{k.count}
</span>
</button>
</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-gray-200 px-5 py-3 @2xl:px-8">
<p id={hintId} className="min-w-0 flex-1 basis-60 text-[11.5px] leading-[1.7] text-pretty text-gray-500">
{still
? "札を選んで ← → で前後へ、↑ でいちばん上、↓ でいちばん下へ移せます。"
: "ドラッグで投げられます。キーボードは札を選んで ← → で押す・↑ で放り上げる・↓ で押し込む"}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
apiRef.current?.shake();
say(still ? "札の並びを崩しました" : "箱をゆすりました");
}}
className={`bg-amber-600 px-4 py-1.5 text-[12.5px] font-bold text-white transition-colors hover:bg-amber-700 motion-reduce:transition-none ${RING}`}
>
ゆする
</button>
<button
type="button"
onClick={() => {
apiRef.current?.redrop();
say(still ? "件数の多い順に並べ直しました" : "札を上から落とし直しました");
}}
className={`border border-gray-300 px-4 py-1.5 text-[12.5px] font-bold text-gray-700 transition-colors hover:border-gray-900 motion-reduce:transition-none ${RING}`}
>
{still ? "並べ直す" : "落とし直す"}
</button>
</div>
</div>
{/* 狭い枠では数値3つを1行に、札の名前は折り返さず読めるよう最後の行を全幅にする */}
<dl className="grid grid-cols-3 border-t border-gray-200 @2xl:grid-cols-[1fr_1fr_1fr_1.6fr]">
{readouts.map((r, i) => (
<div
key={r.label}
className={`flex min-w-0 justify-between gap-x-3 gap-y-1 px-4 py-3 @2xl:flex-row @2xl:items-baseline @2xl:px-6 ${
i < 3 ? "flex-col" : "col-span-3 items-baseline border-t border-gray-200 @2xl:col-span-1 @2xl:border-t-0"
} ${i > 0 ? "border-gray-200 @2xl:border-l" : ""} ${i === 1 || i === 2 ? "border-l" : ""}`}
>
<dt className="shrink-0 text-[11px] tracking-[0.08em] text-gray-500">{r.label}</dt>
<dd className="min-w-0 truncate text-[13px] @2xl:text-right font-bold text-gray-900 tabular-nums">
{r.value}
{r.unit && <span className="ml-0.5 text-[10px] font-normal text-gray-400">{r.unit}</span>}
</dd>
</div>
))}
</dl>
<p role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announce}
</p>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/physics-keyword-pit.json