BLK-92Blocks

分解図(重なった層がスクロールで離れて名前が出る)

1枚の厚い板に見える仕組みが、スクロールに合わせて斜め上へ4層に分かれ、各層に名前が付く分解図(exploded view)。見本は予約サービスを「画面・仕組み・データ・運用」に分けたもので、天板にはそれぞれ予約フォームとカレンダー・判定の流れ図・台帳の表・監視のグラフを描き、右の一覧で層を選ぶと中身・変える頻度・担当・止まったときの影響が読める。上へスクロールし直すと元の1枚に重なる。分解は1本のベクトル E で表し、層 k を (k−中央)×(厚み+p×E) だけずらすので、全ての層の同じ角が1本の直線に乗り、組み立て線を線分1本の伸び縮みで描ける。3D はオルソカメラにして 2D の図と同じ投影を使い、SVG の viewBox と視錐台を同じ数値から張るため、3D・平面図・OG画像の構図が一致する。スクロール連動は rAF で間引いた処理が節に CSS 変数 --p を1つ書くだけで、SVG の層は calc 入りの translate、ラベルは clamp() の opacity で動き、React の再描画は「重なった/分かれた」の境目をまたいだときだけ。入力の正はネイティブのスライダー(矢印・PageUp/Down・Home/End)と「重ねる/分ける」ボタンで、触れるとスクロール連動は外れ、チェックボックスで戻せる。3Dの図はドラッグか矢印キーで向きを変え、板を押すとその層を選べる。3Dライブラリは画面に入った実寸表示のときだけ動的importで読み、一覧の縮小カード・WebGL非対応・コンテキスト喪失では同じ図をSVGで出す。prefers-reduced-motion ではスクロール連動と補間を止め、分かれ切った静止図のまま読める。英字の小見出し・見出しの句点止め・末尾の矢印リンク・角丸・スクロール出現は使わない。層の中身はサンプル。

追加日:
2026-10-07
依存:
three
tags
#3d #exploded-view #layers #scroll-linked #architecture-diagram #webgl #orthographic #data-visualization #keyboard #no-image #dynamic-import

プレビュー

1枚に見える仕組みを、役割の層に分けて見る

スクロールに合わせて板が斜め上へ4層に分かれ、それぞれに名前が付きます。戻すと元の1枚に重なります。層を選ぶと、中身・変える頻度・止まったときの影響を右で読めます。

分解の度合い

100%4つの層に分かれている

板を押すとその層を選べます

層を選ぶ

02仕組みの層

中身
  • 二重予約の判定
  • 料金と割引の計算
  • 通知の送信
変える頻度
月に数回
担当
サーバー側の実装
止まると
予約が重複する・金額を誤る

"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  useSyncExternalStore,
  type CSSProperties,
  type KeyboardEvent,
} from "react";
import { Montserrat } from "next/font/google";
// 型だけの import。tsc が消すので 3Dライブラリのコードは1バイトも入らない(実体は下の動的 import)。
import type * as THREE from "three";

const montserrat = Montserrat({ weight: ["500", "600", "700"], subsets: ["latin"], display: "swap" });

/**
 * 分解図(重なった層がスクロールで離れて名前が出る)
 *
 * 差別化: section-cut-viewer は1つの立体を平面で切って中を読む図、depth-strata-navigator はカメラが層を1枚ずつ奥へ
 * 貫通する図で、どちらも一度に見えるのは1断面・1層だった。本ブロックは4枚の層を同時に斜め上へ引き離して横に並べ、
 * 役割・中身・変える頻度を見比べさせる。操作軸はスクロール量(戻すと重なる)で、英字の小見出し・見出しの「。」止め・
 * 末尾の矢印リンク・角丸・スクロール出現の掛け方は使わない。
 *
 * 汎用技法メモ:
 * 1. **分解は1本のベクトルで足りる**。層 k を `(k − 中央) × (厚み + p × E)` だけずらすと、どの層の同じ角も1本の直線上に並ぶ。
 *    だから「組み立て線」(角どうしを結ぶ細線)は角に置いた線分1本を伸び縮みさせるだけで描ける(2D は calc 入りの
 *    matrix()、3D は端点2つの書き換え)。中央基準でずらすと分解の途中でも図の重心が動かず、枠の中で構図が崩れない。
 *    p = 0 でも厚みぶんは積んでおくと、重なった状態が「4枚が同じ面で z-fighting する絵」ではなく1枚の厚い板になる。
 * 2. **3D はオルソカメラにして 2D の図と同じ投影を使う**。カメラの右・上ベクトルで射影した座標から SVG の viewBox を作り、
 *    同じ数値で OrthographicCamera の視錐台を張る。3D が立たない環境・縮小カード・OG 画像で出る SVG と、3D の絵の構図が一致する。
 * 3. **スクロール連動は CSS 変数1本で配る**。rAF で間引いたスクロール処理が節に `--p` を書き、SVG の層は
 *    `translate(calc(var(--p) * dx px), …)`、ラベルは `clamp(0, (var(--p) − s) / w, 1)` の opacity で動く。React の再描画は
 *    「重なった/分かれた」の境目をまたいだときだけで、毎フレームの更新は style の書き換え1回で済む。
 *    ラベルの横移動は親を size container にして `cqw` で書くと、図の拡縮に合わせて正しい位置へ乗る。
 * 4. 面の絵は図形のデータ(矩形・線・円・楕円)で1回だけ持ち、SVG ではアフィン行列で天板へ貼り、3D では CanvasTexture に焼く。
 * 5. 入力の正はネイティブの range(分解の度合い)と層のボタン。スクロール連動はチェックボックスで切れ、スライダーに触れると
 *    自動で外れる。prefers-reduced-motion では連動も補間も止め、分かれ切った静止図のまま読める。
 *
 * 層の名前・中身・頻度はすべて First CH のオリジナル(構成を示すための見本)。
 */

type ThreeModule = typeof import("three");

/* ------------------------------------------------------------------ データ */

type LayerId = "screen" | "logic" | "data" | "ops";

type Layer = {
  id: LayerId;
  no: string;
  name: string;
  role: string;
  items: string[];
  cadence: string;
  owner: string;
  outage: string;
  /** 天板の地色 */
  tint: string;
  /** 下から数えた段(0 = いちばん下) */
  k: number;
};

// 上から順に並べる(画面の一覧・ボタンの順)
const LAYERS: Layer[] = [
  {
    id: "screen",
    no: "01",
    name: "画面",
    role: "利用者が触れるところ",
    items: ["予約フォーム", "空き枠のカレンダー", "確認メールの文面"],
    cadence: "週に数回",
    owner: "デザインと画面の実装",
    outage: "予約が取れない・表示が崩れる",
    tint: "#ffffff",
    k: 3,
  },
  {
    id: "logic",
    no: "02",
    name: "仕組み",
    role: "受付の決まりを実行する",
    items: ["二重予約の判定", "料金と割引の計算", "通知の送信"],
    cadence: "月に数回",
    owner: "サーバー側の実装",
    outage: "予約が重複する・金額を誤る",
    tint: "#fcfbfa",
    k: 2,
  },
  {
    id: "data",
    no: "03",
    name: "データ",
    role: "記録を正しく残す",
    items: ["予約の台帳", "顧客の連絡先", "枠の在庫"],
    cadence: "四半期に一度",
    owner: "設計とデータ移行",
    outage: "記録が消える・食い違う",
    tint: "#f9f8f6",
    k: 1,
  },
  {
    id: "ops",
    no: "04",
    name: "運用",
    role: "止めずに動かし続ける",
    items: ["死活監視", "毎晩のバックアップ", "障害時の連絡網"],
    cadence: "毎日",
    owner: "運用の担当",
    outage: "気づくのが遅れる・元に戻せない",
    tint: "#f6f5f2",
    k: 0,
  },
];
const LAYER = Object.fromEntries(LAYERS.map((l) => [l.id, l])) as Record<LayerId, Layer>;

/* --- 天板の絵(u: 0–160 が幅方向、v: 0–110 が奥→手前) --- */

const FU = 160;
const FV = 110;

type Fill = "paper" | "soft" | "amber" | "amberSoft";
type Stroke = "ink" | "mute" | "amber";
type Shape =
  | { t: "rect"; x: number; y: number; w: number; h: number; fill?: Fill; stroke?: Stroke }
  | { t: "line"; pts: number[]; stroke: Stroke; dash?: boolean; close?: boolean; fill?: Fill }
  | { t: "circle"; cx: number; cy: number; r: number; fill?: Fill; stroke?: Stroke }
  | { t: "ellipse"; cx: number; cy: number; rx: number; ry: number; fill?: Fill; stroke?: Stroke };

const FILL: Record<Fill, string> = { paper: "#ffffff", soft: "#e7e5e4", amber: "#d97706", amberSoft: "#fde6c4" };
const STROKE: Record<Stroke, string> = { ink: "#57534e", mute: "#a8a29e", amber: "#d97706" };

function calendar(): Shape[] {
  const out: Shape[] = [];
  for (let r = 0; r < 4; r++) {
    for (let c = 0; c < 7; c++) {
      const on = r === 2 && c === 4;
      out.push({ t: "rect", x: 18 + c * 9.6, y: 38 + r * 10.5, w: 7.6, h: 7.6, fill: on ? "amber" : r === 0 ? "soft" : "paper", stroke: on ? undefined : "mute" });
    }
  }
  return out;
}

const FACES: Record<LayerId, Shape[]> = {
  screen: [
    { t: "rect", x: 10, y: 8, w: 140, h: 94, fill: "paper", stroke: "ink" },
    { t: "line", pts: [10, 18, 150, 18], stroke: "ink" },
    { t: "circle", cx: 15.5, cy: 13, r: 1.6, fill: "soft", stroke: "mute" },
    { t: "circle", cx: 20.5, cy: 13, r: 1.6, fill: "soft", stroke: "mute" },
    { t: "circle", cx: 25.5, cy: 13, r: 1.6, fill: "soft", stroke: "mute" },
    { t: "rect", x: 18, y: 25, w: 52, h: 6, fill: "soft" },
    ...calendar(),
    { t: "rect", x: 98, y: 38, w: 44, h: 8, fill: "paper", stroke: "mute" },
    { t: "rect", x: 98, y: 50, w: 44, h: 8, fill: "paper", stroke: "mute" },
    { t: "rect", x: 98, y: 62, w: 44, h: 8, fill: "paper", stroke: "mute" },
    { t: "rect", x: 98, y: 78, w: 44, h: 10, fill: "amber" },
  ],
  logic: [
    { t: "rect", x: 10, y: 45, w: 26, h: 20, fill: "paper", stroke: "ink" },
    { t: "line", pts: [36, 55, 52, 55], stroke: "ink" },
    { t: "line", pts: [52, 55, 66, 43, 80, 55, 66, 67], stroke: "amber", close: true, fill: "amberSoft" },
    { t: "line", pts: [80, 55, 88, 55, 88, 30, 98, 30], stroke: "ink" },
    { t: "line", pts: [88, 55, 88, 80, 98, 80], stroke: "ink" },
    { t: "rect", x: 98, y: 22, w: 32, h: 16, fill: "paper", stroke: "ink" },
    { t: "rect", x: 98, y: 72, w: 32, h: 16, fill: "paper", stroke: "ink" },
    { t: "line", pts: [130, 30, 140, 30, 140, 80, 130, 80], stroke: "mute" },
    { t: "line", pts: [140, 55, 146, 55], stroke: "ink" },
    { t: "circle", cx: 150, cy: 55, r: 4.5, fill: "amber" },
    { t: "line", pts: [16, 14, 66, 14, 66, 43], stroke: "mute", dash: true },
    { t: "line", pts: [16, 96, 66, 96, 66, 67], stroke: "mute", dash: true },
    { t: "rect", x: 10, y: 9, w: 6, h: 10, fill: "soft" },
    { t: "rect", x: 10, y: 91, w: 6, h: 10, fill: "soft" },
  ],
  data: [
    { t: "rect", x: 10, y: 12, w: 96, h: 86, fill: "paper", stroke: "ink" },
    { t: "rect", x: 10, y: 12, w: 96, h: 12, fill: "soft", stroke: "ink" },
    { t: "rect", x: 10, y: 48, w: 96, h: 12, fill: "amberSoft", stroke: "amber" },
    { t: "line", pts: [10, 36, 106, 36], stroke: "mute" },
    { t: "line", pts: [10, 72, 106, 72], stroke: "mute" },
    { t: "line", pts: [10, 84, 106, 84], stroke: "mute" },
    { t: "line", pts: [40, 12, 40, 98], stroke: "mute" },
    { t: "line", pts: [74, 12, 74, 98], stroke: "mute" },
    { t: "line", pts: [118, 24, 118, 48], stroke: "ink" },
    { t: "line", pts: [146, 24, 146, 48], stroke: "ink" },
    { t: "ellipse", cx: 132, cy: 48, rx: 14, ry: 5, fill: "paper", stroke: "ink" },
    { t: "ellipse", cx: 132, cy: 24, rx: 14, ry: 5, fill: "paper", stroke: "ink" },
    { t: "line", pts: [118, 64, 118, 88], stroke: "ink" },
    { t: "line", pts: [146, 64, 146, 88], stroke: "ink" },
    { t: "ellipse", cx: 132, cy: 88, rx: 14, ry: 5, fill: "paper", stroke: "ink" },
    { t: "ellipse", cx: 132, cy: 64, rx: 14, ry: 5, fill: "amberSoft", stroke: "amber" },
  ],
  ops: [
    { t: "line", pts: [28, 14, 150, 14], stroke: "mute", dash: true },
    { t: "line", pts: [28, 32, 150, 32], stroke: "mute", dash: true },
    { t: "line", pts: [28, 50, 150, 50], stroke: "mute", dash: true },
    { t: "line", pts: [12, 8, 12, 70, 150, 70], stroke: "ink" },
    { t: "rect", x: 16, y: 10, w: 8, h: 4, fill: "soft" },
    { t: "line", pts: [14, 60, 26, 52, 40, 56, 54, 42, 68, 47, 82, 31, 96, 37, 110, 23, 124, 35, 138, 27, 150, 31], stroke: "amber" },
    { t: "circle", cx: 110, cy: 23, r: 2.6, fill: "amber" },
    ...Array.from({ length: 14 }, (_, i): Shape => ({ t: "rect", x: 12 + i * 9.8, y: 82, w: 7.4, h: 10, fill: i === 9 ? "amber" : "soft" })),
  ],
};

/* ------------------------------------------------------------------ 投影(2D の図と 3D のカメラの共通の正) */

type V3 = readonly [number, number, number];

const PW = 1.6; // 板の幅(x)
const PD = 1.1; // 板の奥行き(z)
const PT = 0.06; // 板の厚み(y)
/** 分解の向き。層 k は (k − 1.5) × p × E だけずれる(斜め上・少し左へ) */
const E = [-0.28, 0.82, 0] as const;
const MID = 1.5;
/** 重なった状態でも板は厚みぶん積み上がっている(1枚の厚い板に見える) */
const base = (k: number): V3 => [0, (k - MID) * PT, 0];
/** 層 k の板の中心(分解の度合い p) */
const plateAt = (k: number, p: number): V3 => {
  const b = base(k);
  const o = (k - MID) * p;
  return [b[0] + o * E[0], b[1] + o * E[1], b[2] + o * E[2]];
};

const YAW0 = 0.62;
const ELEV0 = 0.5;
const S = 200; // world 1 = 200 viewBox 単位


function basis(yaw: number, elev: number) {
  const r: V3 = [Math.cos(yaw), 0, -Math.sin(yaw)];
  const u: V3 = [-Math.sin(yaw) * Math.sin(elev), Math.cos(elev), -Math.cos(yaw) * Math.sin(elev)];
  return { r, u };
}
const B0 = basis(YAW0, ELEV0);
const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
/** world → viewBox(y は下向き) */
const proj = (p: V3): [number, number] => [S * dot(p, B0.r), -S * dot(p, B0.u)];

const corner = (sx: number, sy: number, sz: number): V3 => [(sx * PW) / 2, (sy * PT) / 2, (sz * PD) / 2];
const ES = proj(E as unknown as V3);

// viewBox: 分かれ切った状態の全角+右にラベルの余白
const LABEL_ROOM = 230;
const PAD = 22;
const VB = (() => {
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (const l of LAYERS) {
    const o = plateAt(l.k, 1);
    for (const sx of [-1, 1])
      for (const sy of [-1, 1])
        for (const sz of [-1, 1]) {
          const c = corner(sx, sy, sz);
          const [x, y] = proj([c[0] + o[0], c[1] + o[1], c[2] + o[2]]);
          x0 = Math.min(x0, x);
          x1 = Math.max(x1, x);
          y0 = Math.min(y0, y);
          y1 = Math.max(y1, y);
        }
  }
  return { x: x0 - PAD, y: y0 - PAD, w: x1 - x0 + PAD * 2 + LABEL_ROOM, h: y1 - y0 + PAD * 2 };
})();

const pts = (list: V3[]) => list.map((c) => proj(c).map((n) => n.toFixed(1)).join(",")).join(" ");
const TOP = pts([corner(-1, 1, -1), corner(1, 1, -1), corner(1, 1, 1), corner(-1, 1, 1)]);
const SIDE_X = pts([corner(1, 1, 1), corner(1, 1, -1), corner(1, -1, -1), corner(1, -1, 1)]);
const SIDE_Z = pts([corner(-1, 1, 1), corner(1, 1, 1), corner(1, -1, 1), corner(-1, -1, 1)]);
// 天板の (u, v) → viewBox のアフィン行列
const FACE_MATRIX = (() => {
  const o = proj(corner(-1, 1, -1));
  const du = proj([PW / FU, 0, 0]);
  const dv = proj([0, 0, PD / FV]);
  return `matrix(${[du[0], du[1], dv[0], dv[1], o[0], o[1]].map((n) => n.toFixed(4)).join(" ")})`;
})();
// ラベルの付け根: 天板の右奥の角(重なった状態では4枚とも同じ点)
const ANCHOR_W: V3 = corner(1, 1, -1);
const ANCHOR = proj(ANCHOR_W);
// 組み立て線を通す角。手前右の角の線は天板の上を横切って絵を濁すので引かない
const GUIDE_CORNERS: V3[] = [
  [-PW / 2, 0, -PD / 2],
  [PW / 2, 0, -PD / 2],
  [-PW / 2, 0, PD / 2],
];

/**
 * 組み立て線の 2D 変形。線分 ±U(U = 1.5E の投影)の端を「p·U + W」(W = 1.5 枚ぶんの厚みの投影)へ写す線形写像
 * M = p·I + W·Uᵀ/|U|² を CSS の matrix() に calc で書く。板の積み上げ(厚み)と分解(E)の両方に角が乗り続ける。
 */
const GUIDE_MATRIX = (() => {
  const U = proj([MID * E[0], MID * E[1], MID * E[2]]);
  const Wv = proj([0, MID * PT, 0]);
  const n = U[0] * U[0] + U[1] * U[1];
  const f = (x: number) => x.toFixed(5);
  return `matrix(calc(var(--p) + ${f((Wv[0] * U[0]) / n)}), ${f((Wv[1] * U[0]) / n)}, ${f((Wv[0] * U[1]) / n)}, calc(var(--p) + ${f((Wv[1] * U[1]) / n)}), 0, 0)`;
})();

/** 層ラベルの出る区間(上の層から順に) */
const reveal = (i: number) => ({ s: 0.3 + 0.12 * i, w: 0.28 });
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));

/* ------------------------------------------------------------------ 面の絵(SVG と canvas) */

function FaceSvg({ id }: { id: LayerId }) {
  return (
    <g transform={FACE_MATRIX}>
      {FACES[id].map((s, i) => {
        const common = {
          fill: s.fill ? FILL[s.fill] : "none",
          stroke: s.stroke ? STROKE[s.stroke] : "none",
          strokeWidth: s.stroke === "amber" ? 1.5 : 1,
          vectorEffect: "non-scaling-stroke" as const,
        };
        if (s.t === "rect") return <rect key={i} x={s.x} y={s.y} width={s.w} height={s.h} {...common} />;
        if (s.t === "circle") return <circle key={i} cx={s.cx} cy={s.cy} r={s.r} {...common} />;
        if (s.t === "ellipse") return <ellipse key={i} cx={s.cx} cy={s.cy} rx={s.rx} ry={s.ry} {...common} />;
        const p = s.pts.filter((_, j) => j % 2 === 0).map((x, j) => `${x},${s.pts[j * 2 + 1]}`).join(" ");
        const Tag = s.close ? "polygon" : "polyline";
        return <Tag key={i} points={p} {...common} fill={s.close && s.fill ? FILL[s.fill] : "none"} strokeDasharray={s.dash ? "3 3" : undefined} />;
      })}
    </g>
  );
}

function drawFace(ctx: CanvasRenderingContext2D, layer: Layer, px: number) {
  const k = px / FU;
  ctx.fillStyle = layer.tint;
  ctx.fillRect(0, 0, px, FV * k);
  ctx.lineJoin = "round";
  ctx.lineCap = "round";
  for (const s of FACES[layer.id]) {
    ctx.beginPath();
    if (s.t === "rect") ctx.rect(s.x * k, s.y * k, s.w * k, s.h * k);
    else if (s.t === "circle") ctx.arc(s.cx * k, s.cy * k, s.r * k, 0, Math.PI * 2);
    else if (s.t === "ellipse") ctx.ellipse(s.cx * k, s.cy * k, s.rx * k, s.ry * k, 0, 0, Math.PI * 2);
    else {
      for (let j = 0; j < s.pts.length; j += 2) {
        if (j === 0) ctx.moveTo(s.pts[0] * k, s.pts[1] * k);
        else ctx.lineTo(s.pts[j] * k, s.pts[j + 1] * k);
      }
      if (s.close) ctx.closePath();
    }
    const fill = s.t === "line" ? (s.close ? s.fill : undefined) : s.fill;
    if (fill) {
      ctx.fillStyle = FILL[fill];
      ctx.fill();
    }
    if (s.stroke) {
      ctx.strokeStyle = STROKE[s.stroke];
      ctx.lineWidth = (s.stroke === "amber" ? 1.1 : 0.7) * k;
      ctx.setLineDash(s.t === "line" && s.dash ? [3 * k, 3 * k] : []);
      ctx.stroke();
    }
  }
  // 天板の縁
  ctx.setLineDash([]);
  ctx.strokeStyle = "#d6d3d1";
  ctx.lineWidth = 1.2 * k;
  ctx.strokeRect(0.6 * k, 0.6 * k, (FU - 1.2) * k, (FV - 1.2) * k);
}

/* ------------------------------------------------------------------ 3D(three) */

type ViewerApi = {
  setProgress: (p: number) => void;
  setSelected: (id: LayerId) => void;
  nudge: (dYaw: number, dElev: number) => void;
  recenter: () => void;
  setRunning: (running: boolean) => void;
  dispose: () => void;
};

type ViewerOptions = {
  canvas: HTMLCanvasElement;
  host: HTMLElement;
  labels: (HTMLElement | null)[];
  progress: number;
  selected: LayerId;
  onPick: (id: LayerId) => void;
  onFirstFrame: () => void;
  onFail: () => void;
};

const YAW_RANGE = 0.32;
const clampYaw = (y: number) => Math.max(YAW0 - YAW_RANGE, Math.min(YAW0 + YAW_RANGE, y));
const clampElev = (e: number) => Math.max(0.3, Math.min(0.78, e));

function createViewer(T: ThreeModule, opts: ViewerOptions): ViewerApi {
  const { canvas, host, labels, onPick, onFirstFrame, onFail } = opts;

  const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "low-power" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.toneMapping = T.NeutralToneMapping;

  const scene = new T.Scene();
  // 2D の viewBox と同じ範囲を写すオルソカメラ(viewBox の y は下向きなので上下を反転する)
  const camera = new T.OrthographicCamera(VB.x / S, (VB.x + VB.w) / S, -VB.y / S, -(VB.y + VB.h) / S, 0.1, 40);

  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.Material[] = [];
  const textures: THREE.Texture[] = [];
  const keepG = <G extends THREE.BufferGeometry>(g: G) => (geometries.push(g), g);
  const keepM = <M extends THREE.Material>(m: M) => (materials.push(m), m);

  scene.add(new T.HemisphereLight(0xffffff, 0xd6d3d1, 1.6));
  const key = new T.DirectionalLight(0xffffff, 1.5);
  key.position.set(1.5, 3, 2.4);
  scene.add(key);

  const boxGeo = keepG(new T.BoxGeometry(PW, PT, PD));
  const boxEdges = keepG(new T.EdgesGeometry(boxGeo));
  const faceGeo = keepG(new T.PlaneGeometry(PW, PD));
  faceGeo.rotateX(-Math.PI / 2);
  const edgeMat = keepM(new T.LineBasicMaterial({ color: 0x8a837c }));
  const pickEdgeMat = keepM(new T.LineBasicMaterial({ color: 0xd97706, toneMapped: false }));
  const sideMat = keepM(new T.MeshStandardMaterial({ color: 0xf5f3ef, roughness: 0.9, metalness: 0 }));
  const pickSideMat = keepM(new T.MeshStandardMaterial({ color: 0xfde6c4, roughness: 0.9, metalness: 0 }));
  const maxAniso = renderer.capabilities.getMaxAnisotropy();

  type Plate = { layer: Layer; group: THREE.Group; box: THREE.Mesh; edges: THREE.LineSegments; face: THREE.Mesh };
  const plates: Plate[] = LAYERS.map((layer) => {
    const group = new T.Group();
    const box = new T.Mesh(boxGeo, sideMat);
    box.userData.id = layer.id;
    const edges = new T.LineSegments(boxEdges, edgeMat);
    // 天板の絵は 2D と同じ図形データから焼く
    const c = document.createElement("canvas");
    c.width = 1024;
    c.height = Math.round((1024 * FV) / FU);
    const ctx = c.getContext("2d");
    if (ctx) drawFace(ctx, layer, c.width);
    const tex = new T.CanvasTexture(c);
    tex.colorSpace = T.SRGBColorSpace;
    tex.anisotropy = maxAniso;
    textures.push(tex);
    const face = new T.Mesh(faceGeo, keepM(new T.MeshBasicMaterial({ map: tex, toneMapped: false })));
    face.position.y = PT / 2 + 0.0015;
    face.userData.id = layer.id;
    group.add(box, edges, face);
    scene.add(group);
    return { layer, group, box, edges, face };
  });

  // 組み立て線: いちばん下の板の角から、いちばん上の板の同じ角まで(途中の板の角も同じ直線上にある)
  const guideMat = keepM(new T.LineDashedMaterial({ color: 0xb5aea6, dashSize: 0.025, gapSize: 0.025 }));
  const guides = GUIDE_CORNERS.map((c) => {
    const geo = keepG(new T.BufferGeometry());
    geo.setAttribute("position", new T.Float32BufferAttribute(new Float32Array(6), 3));
    const line = new T.LineSegments(geo, guideMat);
    line.position.set(c[0], c[1], c[2]);
    scene.add(line);
    return line;
  });

  /* --- 状態 --- */
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let motionOK = !motionQuery.matches;
  let running = false;
  let raf = 0;
  let last = 0;
  let firstFrame = true;
  let pTarget = opts.progress;
  let pShown = opts.progress;
  let selected = opts.selected;
  let yaw = YAW0;
  let elev = ELEV0;
  let yawTarget = YAW0;
  let elevTarget = ELEV0;
  let width = 1;
  let height = 1;
  const v = new T.Vector3();

  const apply = () => {
    for (const pl of plates) {
      const o = plateAt(pl.layer.k, pShown);
      pl.group.position.set(o[0], o[1], o[2]);
      const on = pl.layer.id === selected;
      pl.box.material = on ? pickSideMat : sideMat;
      pl.edges.material = on ? pickEdgeMat : edgeMat;
    }
    const lo = plateAt(0, pShown);
    const hi = plateAt(LAYERS.length - 1, pShown);
    for (const g of guides) {
      const pos = g.geometry.getAttribute("position") as THREE.BufferAttribute;
      pos.setXYZ(0, lo[0], lo[1], lo[2]);
      pos.setXYZ(1, hi[0], hi[1], hi[2]);
      pos.needsUpdate = true;
      g.computeLineDistances();
      g.visible = pShown > 0.01;
    }
    const d = new T.Vector3(Math.sin(yaw) * Math.cos(elev), Math.sin(elev), Math.cos(yaw) * Math.cos(elev));
    camera.position.copy(d.multiplyScalar(12));
    camera.up.set(0, 1, 0);
    camera.lookAt(0, 0, 0);
    camera.updateMatrixWorld();
    // ラベル: 右奥の角を画面へ写して置く
    LAYERS.forEach((l, i) => {
      const el = labels[i];
      if (!el) return;
      const o = plateAt(l.k, pShown);
      v.set(ANCHOR_W[0] + o[0], ANCHOR_W[1] + o[1], ANCHOR_W[2] + o[2]).project(camera);
      const x = ((v.x + 1) / 2) * width;
      const y = ((1 - v.y) / 2) * height;
      const r = reveal(i);
      el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translateY(-50%)`;
      el.style.opacity = clamp01((pShown - r.s) / r.w).toFixed(3);
    });
  };

  const render = () => {
    apply();
    renderer.render(scene, camera);
    if (firstFrame) {
      firstFrame = false;
      onFirstFrame();
    }
  };

  const tick = (now: number) => {
    raf = 0;
    const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
    last = now;
    const a = motionOK ? 1 - Math.pow(0.004, dt) : 1;
    pShown += (pTarget - pShown) * a;
    yaw += (yawTarget - yaw) * a;
    elev += (elevTarget - elev) * a;
    const settled = Math.abs(pTarget - pShown) < 0.001 && Math.abs(yawTarget - yaw) < 0.0005 && Math.abs(elevTarget - elev) < 0.0005;
    if (settled) {
      pShown = pTarget;
      yaw = yawTarget;
      elev = elevTarget;
    }
    render();
    if (!settled && running) raf = requestAnimationFrame(tick);
    else last = 0;
  };

  const stop = () => {
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    last = 0;
  };
  const kick = () => {
    if (!running) return;
    if (!motionOK) {
      // 動きを減らす設定では補間せず、最終状態を1枚だけ描く
      stop();
      pShown = pTarget;
      yaw = yawTarget;
      elev = elevTarget;
      render();
      return;
    }
    if (!raf) raf = requestAnimationFrame(tick);
  };
  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    kick();
  };
  motionQuery.addEventListener("change", onMotionChange);

  const resize = () => {
    width = Math.max(1, host.clientWidth);
    height = Math.max(1, host.clientHeight);
    renderer.setSize(width, height, false);
  };

  /* --- 入力: ドラッグで向き、クリックで層を選ぶ --- */
  const ray = new T.Raycaster();
  const ndc = new T.Vector2();
  const pickTargets = plates.flatMap((p) => [p.box, p.face]);
  const pickAt = (clientX: number, clientY: number): LayerId | null => {
    const r = canvas.getBoundingClientRect();
    ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
    ray.setFromCamera(ndc, camera);
    const hit = ray.intersectObjects(pickTargets, false)[0];
    return (hit?.object.userData.id as LayerId | undefined) ?? null;
  };
  let dragging = false;
  let dragged = false;
  let sx = 0;
  let sy = 0;
  let lx = 0;
  let ly = 0;
  const onPointerDown = (e: PointerEvent) => {
    dragging = true;
    dragged = false;
    sx = lx = e.clientX;
    sy = ly = e.clientY;
    canvas.setPointerCapture(e.pointerId);
    canvas.style.cursor = "grabbing";
  };
  const onPointerMove = (e: PointerEvent) => {
    if (!dragging) {
      canvas.style.cursor = pickAt(e.clientX, e.clientY) ? "pointer" : "grab";
      return;
    }
    if (Math.hypot(e.clientX - sx, e.clientY - sy) > 5) dragged = true;
    if (!dragged) return;
    yawTarget = clampYaw(yawTarget - (e.clientX - lx) * 0.006);
    elevTarget = clampElev(elevTarget + (e.clientY - ly) * 0.005);
    lx = e.clientX;
    ly = e.clientY;
    kick();
  };
  const onPointerUp = (e: PointerEvent) => {
    if (!dragging) return;
    dragging = false;
    if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
    canvas.style.cursor = "grab";
    if (!dragged && e.type === "pointerup") {
      const id = pickAt(e.clientX, e.clientY);
      if (id) onPick(id);
    }
  };
  canvas.addEventListener("pointerdown", onPointerDown);
  canvas.addEventListener("pointermove", onPointerMove);
  canvas.addEventListener("pointerup", onPointerUp);
  canvas.addEventListener("pointercancel", onPointerUp);

  const onContextLost = (e: Event) => {
    e.preventDefault();
    stop();
    onFail();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);

  // canvas の寸法を書き換えると中身が消えるので、止まっていても必ず1枚描き直す
  const ro = new ResizeObserver(() => {
    resize();
    render();
  });
  ro.observe(host);
  resize();
  render();

  return {
    setProgress: (p) => {
      pTarget = p;
      if (running) kick();
      else {
        // 画面外で変わった値は戻ったときに描き直す(ここでは状態だけ合わせる)
        pShown = p;
      }
    },
    setSelected: (id) => {
      selected = id;
      if (!raf) render();
    },
    nudge: (dYaw, dElev) => {
      yawTarget = clampYaw(yawTarget + dYaw);
      elevTarget = clampElev(elevTarget + dElev);
      kick();
    },
    recenter: () => {
      yawTarget = YAW0;
      elevTarget = ELEV0;
      kick();
    },
    setRunning: (next) => {
      running = next;
      if (!next) stop();
      else {
        render();
        kick();
      }
    },
    dispose: () => {
      stop();
      ro.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", onPointerUp);
      canvas.removeEventListener("pointercancel", onPointerUp);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      for (const t of textures) t.dispose();
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ 2D(SVG・CSS 変数 --p で動く) */

const pct = (n: number) => `${n.toFixed(3)}cqw`;

function DiagramSvg({ selected, onPick }: { selected: LayerId; onPick: (id: LayerId) => void }) {
  const bottomFirst = [...LAYERS].sort((a, b) => a.k - b.k);
  return (
    <svg viewBox={`${VB.x.toFixed(1)} ${VB.y.toFixed(1)} ${VB.w.toFixed(1)} ${VB.h.toFixed(1)}`} className="absolute inset-0 h-full w-full" aria-hidden="true">
      {/* 組み立て線: 角を通る線分を GUIDE_MATRIX で伸び縮みさせる */}
      {GUIDE_CORNERS.map((c, i) => {
        const [x, y] = proj(c);
        const [ax, ay] = proj([c[0] - MID * E[0], c[1] - MID * E[1], c[2] - MID * E[2]]);
        const [bx, by] = proj([c[0] + MID * E[0], c[1] + MID * E[1], c[2] + MID * E[2]]);
        return (
          <line
            key={i}
            x1={ax}
            y1={ay}
            x2={bx}
            y2={by}
            stroke="#c8c2bb"
            strokeWidth={1}
            strokeDasharray="4 4"
            vectorEffect="non-scaling-stroke"
            className="transition-transform duration-300 ease-out motion-reduce:transition-none"
            style={{ transformOrigin: `${x.toFixed(1)}px ${y.toFixed(1)}px`, transform: GUIDE_MATRIX }}
          />
        );
      })}
      {bottomFirst.map((l) => {
        const o = l.k - MID;
        const b = proj(base(l.k));
        const on = l.id === selected;
        return (
          <g
            key={l.id}
            onClick={() => onPick(l.id)}
            className="cursor-pointer transition-transform duration-300 ease-out motion-reduce:transition-none"
            style={{ transform: `translate(calc(${b[0].toFixed(2)}px + var(--p) * ${(o * ES[0]).toFixed(2)}px), calc(${b[1].toFixed(2)}px + var(--p) * ${(o * ES[1]).toFixed(2)}px))` }}
          >
            <polygon points={SIDE_X} fill={on ? "#fbd9a6" : "#e7e5e4"} stroke={on ? "#d97706" : "#8a837c"} strokeWidth={on ? 1.5 : 1} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
            <polygon points={SIDE_Z} fill={on ? "#fde6c4" : "#f1efec"} stroke={on ? "#d97706" : "#8a837c"} strokeWidth={on ? 1.5 : 1} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
            <polygon points={TOP} fill={l.tint} />
            <FaceSvg id={l.id} />
            <polygon points={TOP} fill="none" stroke={on ? "#d97706" : "#8a837c"} strokeWidth={on ? 1.5 : 1} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
          </g>
        );
      })}
    </svg>
  );
}

function LabelBody({ layer, on }: { layer: Layer; on: boolean }) {
  return (
    <>
      <span aria-hidden="true" className={`h-px w-4 shrink-0 @lg/stage:w-6 ${on ? "bg-amber-600" : "bg-gray-400"}`} />
      <span className={`${montserrat.className} text-[10px] font-semibold tabular-nums @lg/stage:text-[11px] ${on ? "text-amber-700" : "text-gray-500"}`}>{layer.no}</span>
      <span className={`text-[11px] font-bold @lg/stage:text-[13px] ${on ? "text-amber-700" : "text-gray-900"}`}>{layer.name}</span>
      <span className="hidden text-[11px] text-gray-500 @xl/stage:inline">{layer.role}</span>
    </>
  );
}

/* ------------------------------------------------------------------ 本体 */

type Phase = "collapsed" | "between" | "exploded";
const PHASE_TEXT: Record<Phase, string> = {
  collapsed: "1枚に重なっている",
  between: "分かれつつある",
  exploded: "4つの層に分かれている",
};
const phaseOf = (p: number): Phase => (p <= 0.02 ? "collapsed" : p >= 0.98 ? "exploded" : "between");

const motionQueryText = "(prefers-reduced-motion: reduce)";
const subscribeMotion = (cb: () => void) => {
  const m = window.matchMedia(motionQueryText);
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
};
const getMotion = () => window.matchMedia(motionQueryText).matches;
const getMotionServer = () => false;

const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";

export default function ExplodedLayerDiagram({ headingTag: Heading = "h2" }: { headingTag?: "h1" | "h2" | "p" }) {
  const Sub = Heading === "h1" ? "h2" : Heading === "h2" ? "h3" : "p";
  const uid = useId().replace(/:/g, "");
  const headingId = `exploded-heading-${uid}`;
  const sliderId = `exploded-slider-${uid}`;
  const linkId = `exploded-link-${uid}`;
  const hintId = `exploded-hint-${uid}`;

  const rootRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sliderRef = useRef<HTMLInputElement>(null);
  const readoutRef = useRef<HTMLSpanElement>(null);
  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const apiRef = useRef<ViewerApi | null>(null);
  const pRef = useRef(1);
  const phaseRef = useRef<Phase>("exploded");
  const fullSizeRef = useRef(false);

  const [live, setLive] = useState(false);
  const [inView, setInView] = useState(false);
  const [pageVisible, setPageVisible] = useState(true);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);
  const [selected, setSelected] = useState<LayerId>("logic");
  const [linkPref, setLinkPref] = useState(true);
  const [phase, setPhase] = useState<Phase>("exploded");
  const [announce, setAnnounce] = useState("");
  const reduced = useSyncExternalStore(subscribeMotion, getMotion, getMotionServer);
  const linked = linkPref && !reduced;

  const selectedRef = useRef<LayerId>("logic");
  useEffect(() => {
    selectedRef.current = selected;
  }, [selected]);

  // 分解の度合いの唯一の書き込み口。毎フレーム呼ばれても React の再描画は境目をまたいだときだけ
  const applyP = useCallback((raw: number) => {
    const p = clamp01(Math.round(raw * 100) / 100);
    pRef.current = p;
    rootRef.current?.style.setProperty("--p", String(p));
    const ph = phaseOf(p);
    const n = Math.round(p * 100);
    const slider = sliderRef.current;
    if (slider) {
      if (slider.value !== String(n)) slider.value = String(n);
      slider.setAttribute("aria-valuetext", `${n}%・${PHASE_TEXT[ph]}`);
    }
    if (readoutRef.current) readoutRef.current.textContent = String(n);
    apiRef.current?.setProgress(p);
    if (phaseRef.current !== ph) {
      phaseRef.current = ph;
      setPhase(ph);
    }
  }, []);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
  // rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、3Dライブラリもスクロール連動も使わない。
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const fullSize = el.offsetWidth > 0 ? rect.width / el.offsetWidth > 0.75 : true;
    fullSizeRef.current = fullSize;
    const io = new IntersectionObserver(
      ([entry]) => {
        setInView(entry.isIntersecting);
        if (entry.isIntersecting && fullSize) setLive(true);
      },
      { rootMargin: "160px" },
    );
    io.observe(el);
    const onVisibility = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  // スクロール連動: 図の上端がビューポートの 90% → 35% を通る間に 0 → 1。戻すと重なる
  useEffect(() => {
    const el = stageRef.current;
    if (!linked || !el || !fullSizeRef.current) return;
    let raf = 0;
    const measure = () => {
      raf = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight || 1;
      applyP((vh * 0.9 - r.top) / (vh * 0.55));
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(measure);
    };
    onScroll();
    // capture: スクロールする祖先が window 以外(プレビュー枠など)でも拾う
    window.addEventListener("scroll", onScroll, { passive: true, capture: true });
    window.addEventListener("resize", onScroll);
    return () => {
      if (raf) cancelAnimationFrame(raf);
      window.removeEventListener("scroll", onScroll, { capture: true });
      window.removeEventListener("resize", onScroll);
    };
  }, [linked, applyP]);

  useEffect(() => {
    if (!live) return;
    const canvas = canvasRef.current;
    const host = stageRef.current;
    if (!canvas || !host) return;
    let cancelled = false;
    const boot = async () => {
      // 非対応なら重いライブラリを取りに行かず、SVG の図のまま確定させる
      const probe = document.createElement("canvas");
      const gl = probe.getContext("webgl2") ?? (probe.getContext("webgl") as WebGLRenderingContext | null);
      if (!gl) {
        setFailed(true);
        return;
      }
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      const T = await import("three");
      if (cancelled) return;
      apiRef.current = createViewer(T, {
        canvas,
        host,
        labels: labelRefs.current,
        progress: pRef.current,
        selected: selectedRef.current,
        onPick: (id) => setSelected(id),
        onFirstFrame: () => setReady(true),
        onFail: () => setFailed(true),
      });
    };
    boot().catch(() => {
      if (!cancelled) setFailed(true);
    });
    return () => {
      cancelled = true;
      apiRef.current?.dispose();
      apiRef.current = null;
    };
  }, [live]);

  useEffect(() => {
    apiRef.current?.setRunning(inView && pageVisible && !failed);
  }, [inView, pageVisible, failed, ready]);

  useEffect(() => {
    apiRef.current?.setSelected(selected);
  }, [selected, ready]);

  // 重なった/分かれ切ったときだけ読み上げる(スクロールの途中では黙る)
  const spokenRef = useRef<Phase>("exploded");
  useEffect(() => {
    if (phase === "between") return;
    const t = window.setTimeout(() => {
      if (spokenRef.current === phase) return;
      spokenRef.current = phase;
      setAnnounce(
        phase === "exploded"
          ? `4つの層に分かれました。上から${LAYERS.map((l) => l.name).join("・")}`
          : "4つの層が1枚に重なりました",
      );
    }, 600);
    return () => window.clearTimeout(t);
  }, [phase]);

  const manual = (p: number) => {
    setLinkPref(false);
    applyP(p);
  };

  const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const api = apiRef.current;
    if (!api) return;
    const map: Record<string, [number, number]> = {
      ArrowLeft: [0.08, 0],
      ArrowRight: [-0.08, 0],
      ArrowUp: [0, 0.06],
      ArrowDown: [0, -0.06],
    };
    if (e.key === "Home") {
      e.preventDefault();
      api.recenter();
    } else if (map[e.key]) {
      e.preventDefault();
      api.nudge(map[e.key][0], map[e.key][1]);
    }
  };

  const three = ready && !failed;
  const cur = LAYER[selected];
  const ax = ((ANCHOR[0] - VB.x) / VB.w) * 100;
  const ay = ((ANCHOR[1] - VB.y) / VB.h) * 100;

  return (
    <section ref={rootRef} aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900 [--p:1]">
      <div className="grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_17.5rem] @3xl:items-end">
        <div>
          <Heading id={headingId} className="text-[clamp(1.35rem,3.4vw,2.05rem)] leading-[1.35] font-bold tracking-tight">
            1枚に見える仕組みを、役割の層に分けて見る
          </Heading>
          <p className="mt-3 max-w-[40rem] text-[15px] leading-[1.9] text-gray-600">
            スクロールに合わせて板が斜め上へ4層に分かれ、それぞれに名前が付きます。戻すと元の1枚に重なります。層を選ぶと、中身・変える頻度・止まったときの影響を右で読めます。
          </p>
        </div>
        <div className="border-y border-gray-200 py-3">
          <p className="text-[12px] text-gray-500">分解の度合い</p>
          <p className="mt-1 flex items-baseline gap-1">
            <span ref={readoutRef} className={`${montserrat.className} text-[1.9rem] leading-none font-bold tabular-nums`}>
              100
            </span>
            <span className={`${montserrat.className} text-[13px] font-semibold text-gray-500`}>%</span>
            <span className="ml-3 text-[13px] text-gray-700">{PHASE_TEXT[phase]}</span>
          </p>
        </div>
      </div>

      <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @3xl:grid-cols-[minmax(0,1fr)_17.5rem]">
        <div className="min-w-0">
          <div
            ref={stageRef}
            role={three ? "group" : "img"}
            aria-roledescription={three ? "3D の分解図" : undefined}
            aria-label={
              three
                ? "分解図。ドラッグか矢印キーで向きを変え、Home で元の向きに戻します"
                : `分解図。上から${LAYERS.map((l) => `${l.no} ${l.name}`).join("、")}の4層`
            }
            aria-describedby={three ? hintId : undefined}
            tabIndex={three ? 0 : undefined}
            onKeyDown={three ? onStageKey : undefined}
            className={`@container/stage relative w-full overflow-hidden border border-gray-200 bg-[radial-gradient(circle,#e7e5e4_1px,transparent_1.2px)] bg-[length:18px_18px] ${focusRing}`}
            style={{ aspectRatio: `${VB.w.toFixed(1)} / ${VB.h.toFixed(1)}` }}
          >
            {/* 2D: SSR・縮小カード・WebGL 非対応・3D が立つまでの表示 */}
            <div aria-hidden="true" className={`absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none ${three ? "pointer-events-none opacity-0" : "opacity-100"}`}>
              <DiagramSvg selected={selected} onPick={setSelected} />
              {LAYERS.map((l, i) => {
                const o = l.k - MID;
                const b = proj(base(l.k));
                const r = reveal(i);
                const style: CSSProperties = {
                  left: `${ax.toFixed(2)}%`,
                  top: `${ay.toFixed(2)}%`,
                  transform: `translate(calc(${pct((b[0] / VB.w) * 100)} + var(--p) * ${pct(((o * ES[0]) / VB.w) * 100)}), calc(${pct((b[1] / VB.w) * 100)} + var(--p) * ${pct(((o * ES[1]) / VB.w) * 100)})) translateY(-50%)`,
                  opacity: `clamp(0, calc((var(--p) - ${r.s}) / ${r.w}), 1)`,
                };
                return (
                  <span
                    key={l.id}
                    className="pointer-events-none absolute flex items-center gap-1.5 pl-1 whitespace-nowrap transition-[transform,opacity] duration-300 ease-out motion-reduce:transition-none"
                    style={style}
                  >
                    <LabelBody layer={l} on={l.id === selected} />
                  </span>
                );
              })}
            </div>
            {/* 3D: 画面に入った実寸表示のときだけ。透明な canvas は入力を奪うので、3D でないときは入力を下の SVG へ通す */}
            <div aria-hidden="true" className={`absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none ${three ? "opacity-100" : "pointer-events-none opacity-0"}`}>
              <canvas ref={canvasRef} className="absolute inset-0 h-full w-full cursor-grab touch-pan-y" />
              {LAYERS.map((l, i) => (
                <span
                  key={l.id}
                  ref={(el) => {
                    labelRefs.current[i] = el;
                  }}
                  className="pointer-events-none absolute top-0 left-0 flex items-center gap-1.5 pl-1 whitespace-nowrap opacity-0"
                >
                  <LabelBody layer={l} on={l.id === selected} />
                </span>
              ))}
            </div>
            {failed && (
              <p className="absolute bottom-2 left-3 text-[10px] text-gray-500">3D表示が使えない環境のため、同じ図を平面で表示しています</p>
            )}
          </div>
          <p id={hintId} className="mt-2 text-[12px] leading-relaxed text-gray-500">
            {three ? "図をドラッグすると向きが変わります。板を押すとその層を選べます" : "板を押すとその層を選べます"}
          </p>

          <div className="mt-5 grid gap-4 border-t border-gray-200 pt-5 @xl:grid-cols-[minmax(0,1fr)_auto] @xl:items-end">
            <div>
              <label htmlFor={sliderId} className="text-[13px] font-bold">
                分解の度合い
              </label>
              <input
                ref={sliderRef}
                id={sliderId}
                type="range"
                min={0}
                max={100}
                step={1}
                defaultValue={100}
                aria-valuetext={`100%・${PHASE_TEXT.exploded}`}
                onChange={(e) => manual(Number(e.currentTarget.value) / 100)}
                className={`mt-2 block w-full accent-amber-600 ${focusRing}`}
              />
            </div>
            <div className="flex flex-wrap gap-2">
              <button
                type="button"
                onClick={() => manual(0)}
                aria-pressed={!linked && phase === "collapsed"}
                className={`border px-3 py-1.5 text-[13px] transition-colors motion-reduce:transition-none ${!linked && phase === "collapsed" ? "border-amber-600 text-amber-700" : "border-gray-300 hover:border-amber-600"} ${focusRing}`}
              >
                重ねる
              </button>
              <button
                type="button"
                onClick={() => manual(1)}
                aria-pressed={!linked && phase === "exploded"}
                className={`border px-3 py-1.5 text-[13px] transition-colors motion-reduce:transition-none ${!linked && phase === "exploded" ? "border-amber-600 text-amber-700" : "border-gray-300 hover:border-amber-600"} ${focusRing}`}
              >
                分ける
              </button>
            </div>
            <div className="@xl:col-span-2">
              <label htmlFor={linkId} className="inline-flex items-center gap-2 text-[13px]">
                <input
                  id={linkId}
                  type="checkbox"
                  checked={linked}
                  disabled={reduced}
                  onChange={(e) => setLinkPref(e.currentTarget.checked)}
                  className={`size-4 accent-amber-600 ${focusRing}`}
                />
                スクロールに合わせて動かす
              </label>
              {reduced && <p className="mt-1 text-[12px] text-gray-500">動きを減らす設定のため、スクロールとは連動させていません。スライダーとボタンで切り替えられます</p>}
            </div>
          </div>
        </div>

        <div className="@3xl:border-l @3xl:border-gray-200 @3xl:pl-9">
          <Sub className="text-[13px] font-bold text-gray-500">層を選ぶ</Sub>
          <ol className="mt-3 border-t border-gray-200">
            {LAYERS.map((l) => {
              const on = l.id === selected;
              return (
                <li key={l.id} className="border-b border-gray-200">
                  <button
                    type="button"
                    aria-pressed={on}
                    onClick={() => setSelected(l.id)}
                    className={`grid w-full grid-cols-[2.25rem_minmax(0,1fr)] items-baseline gap-x-2 py-3 text-left transition-colors motion-reduce:transition-none hover:bg-amber-50/60 ${focusRing}`}
                  >
                    <span className={`${montserrat.className} text-[12px] font-semibold tabular-nums ${on ? "text-amber-700" : "text-gray-400"}`}>{l.no}</span>
                    <span>
                      <span className={`block text-[15px] font-bold ${on ? "text-amber-700" : ""}`}>{l.name}</span>
                      <span className="mt-0.5 block text-[12px] text-gray-500">{l.role}</span>
                    </span>
                  </button>
                </li>
              );
            })}
          </ol>

          <div className="mt-6">
            <p className="flex items-baseline gap-2">
              <span className={`${montserrat.className} text-[12px] font-semibold text-amber-700 tabular-nums`}>{cur.no}</span>
              <span className="text-[17px] font-bold">{cur.name}の層</span>
            </p>
            <dl className="mt-3 border-t border-gray-200 text-[13px]">
              <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2 border-b border-gray-200 py-2.5">
                <dt className="text-gray-500">中身</dt>
                <dd>
                  <ul>
                    {cur.items.map((it) => (
                      <li key={it}>{it}</li>
                    ))}
                  </ul>
                </dd>
              </div>
              <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2 border-b border-gray-200 py-2.5">
                <dt className="text-gray-500">変える頻度</dt>
                <dd>{cur.cadence}</dd>
              </div>
              <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2 border-b border-gray-200 py-2.5">
                <dt className="text-gray-500">担当</dt>
                <dd>{cur.owner}</dd>
              </div>
              <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2 border-b border-gray-200 py-2.5">
                <dt className="text-gray-500">止まると</dt>
                <dd>{cur.outage}</dd>
              </div>
            </dl>
          </div>
        </div>
      </div>

      <p className="sr-only" aria-live="polite">
        {announce}
      </p>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/exploded-layer-diagram.json