Exploded Layer Diagram (stacked layers separate on scroll and get named)
An exploded-view diagram: what looks like one thick board separates diagonally upward into four layers as you scroll, and each layer gets a name. The sample splits a booking service into Screen, Logic, Data and Operations; each top face carries its own drawing (booking form and calendar, decision flow, ledger table, monitoring chart), and choosing a layer in the list on the right shows what it holds, how often it changes, who owns it and what breaks if it stops. Scroll back up and the layers stack into one board again. The explosion is a single vector E: layer k moves by (k − middle) × (thickness + p × E), so matching corners of every layer stay on one straight line and each assembly guide is a single segment that stretches. The 3D view uses an orthographic camera with the same projection as the 2D drawing; the SVG viewBox and the camera frustum come from the same numbers, so 3D, the flat drawing and the OG image share one composition. Scroll linking writes a single CSS variable --p to the section from a rAF-throttled handler; SVG layers move with calc() translates and labels fade with clamp() opacity, and React only re-renders when the diagram crosses fully stacked or fully separated. The native range input (arrows, Page Up/Down, Home/End) and the Stack/Separate buttons are the source of truth; touching them unlinks scrolling, and a checkbox links it again. The 3D view turns by drag or arrow keys and pressing a plate selects its layer. three is dynamically imported only when the block is on screen at full size; the scaled gallery card, browsers without WebGL and lost contexts get the same diagram as SVG. With prefers-reduced-motion, scroll linking and tweening stop and the fully separated still diagram stays readable. No English kickers, full-stop headings, trailing arrow links, rounded corners or scroll-in effects. Layer contents are sample data.
- Added:
- 2026-10-07
- Dependencies:
- three
- tags
- #3d #exploded-view #layers #scroll-linked #architecture-diagram #webgl #orthographic #data-visualization #keyboard #no-image #dynamic-import
Preview
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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/exploded-layer-diagram.json