分解図(重なった層がスクロールで離れて名前が出る)
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