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