BLK-94Blocks
物理で積もるキーワード(落ちて転がるタグ)
得意分野の札が上から落ちて転がり、箱の底に積み重なるブロック。札は1枚ずつ読める button で、件数が大きい札ほど大きく重い。ドラッグでつかんで投げられ、「ゆする」で積み山が跳ねて崩れ、「落とし直す」で件数の多い順にもう一度降ってくる。剛体計算は依存なしの自作で、回転する長方形どうしの接触を分離軸で求めて辺を切り取り、法線と摩擦の衝撃量を逐次インパルスで反復して解く。前ステップの衝撃量を接触ごとに引き継ぐので、20枚近く積んでも山が震えない。長方形は180°回しても同じ形なので表示の角度だけ ±90° に畳み、どの札も文字が逆さにならない。落ち着いたら計算を止め、画面外・タブ非表示でも止まる。キーボードでは札を選んで ← → で押す・↑ で放り上げる・↓ で押し込む。prefers-reduced-motion では計算を回さず、件数順に下から積んだ静止した並びを描き、同じキーが並び替え(前後・いちばん上・いちばん下)として働き、結果を aria-live で読み上げる。下部の計測帯には動いている札・床に着いた札・てっぺんの札を出す。角丸・英字の小見出し・末尾の矢印リンク・出現アニメは使わない。
- 追加日:
- 2026-10-10
- 依存:
- なし
- tags
- #block #physics #rigid-body #tags #keywords #drag #throw #interactive #data-visualization #keyboard #accessibility #no-image #no-dependency
プレビュー
得意分野 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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/physics-keyword-pit.json