BLK-91Blocks

フロアマップ(部屋を選ぶと寄って説明が出る)

事務所・店舗・展示会場のフロアを斜め上から見た立体の間取りで示す3Dフロアマップ。部屋を選ぶとカメラがその区画へ寄り、入口からの道順を床の上に線で引いて、距離・歩く時間・道順の手順・広さ・席数・設備を並べる。見本は16×10mの事務所(10室・廊下・受付)で、部屋は矩形と扉の位置だけで書き、壁は4辺から扉を切り欠き・隣室と共有する辺を重複除去して自動生成する。道順は「入口→廊下の中心線→扉→室内」の折れ線で、3Dの帯・2Dの線・距離・手順の文章が同じ計算から出る。寄る動作は見せたい床の範囲を補間し、その範囲の8隅をカメラ空間へ写して正射影カメラの上下左右を決めるため、どの縦横比・向きでも選んだ部屋がちょうど収まる。入力の正は部屋の一覧(aria-pressed のボタン)と前後・全体ボタンで、キーボードだけで操作が完結する。3Dでは床のクリックで部屋を選び、ドラッグか矢印キーで向きを変え、Escapeで全体へ戻る。3Dライブラリ(three)は画面に入った実寸表示のときだけ動的importで読み込み、一覧の縮小カード・WebGL非対応・コンテキスト喪失では同じ角度の等角投影図(SVG)で同じ選択と道順を出す。prefers-reduced-motion ではカメラを補間せず最終状態を1枚だけ描く。英字の小見出し・末尾の矢印リンク・角丸・スクロール出現は使わず、hairline 罫線と余白で組んでいる。間取りと設備はサンプル。

追加日:
2026-10-06
依存:
three
tags
#3d #floor-map #floor-plan #isometric #wayfinding #webgl #orthographic-camera #data-visualization #interactive #keyboard #no-image #dynamic-import

プレビュー

初めて来た人が、迷わず部屋へ着く

フロアを斜め上から見た模型で示します。部屋を選ぶとその区画へ寄り、入口からの道順と距離、広さと設備を並べます。一覧からも同じ部屋を選べます。

床面積
160m²
部屋
10室
席
59席

床をクリックで部屋を選択・ドラッグで回転(矢印キーで回転・Escで全体)

表示中:フロア全体

部屋を選ぶと、入口からの道順・広さ・設備をここに出します。地図の床を押すか、一覧から選んでください。

部屋の一覧

数値は部屋の広さと、入口から歩く距離です。選んだ部屋をもう一度押すと全体に戻ります

間取り・部屋名・設備は見せ方を示すための見本です。部屋を矩形と扉の位置で書き換えれば、壁・道順・距離は同じ計算から作られます。

"use client";

import { useEffect, useId, useRef, useState, 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" });

/**
 * フロアマップ(部屋を選ぶと寄って説明が出る)
 *
 * 差別化: 既存の3D(product-configurator-3d・section-cut-viewer・globe-reach-arcs)は1つの物体を回す・塗る・切る、
 * または地球規模の分布を見せる表現で、area-coverage-map・company-access-map は建物の外の平面地図だった。
 * 本ブロックは建物の中の案内で、部屋を選ぶとカメラがその区画へ寄り、入口からの道順・距離・設備を一覧と同期して出す。
 * 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸・スクロール出現は置かない。
 *
 * 汎用技法メモ:
 * 1. **寄る先は「矩形の範囲」で補間する**。カメラ位置やズーム値を直接補間せず、見せたい床の範囲(x0,z0,x1,z1)を補間し、
 *    毎フレームその範囲の8隅をカメラ空間へ写して OrthographicCamera の left/right/top/bottom を決める。
 *    どの縦横比・どの向きでも、選んだ部屋がちょうど収まる。
 * 2. 間取りは部屋の矩形だけから壁を生成する(4辺→廊下側に扉の切り欠き→隣室と共有する辺は重複除去)。
 *    道順も「入口→廊下の中心線→扉→室内」の折れ線で、3D の帯・2D の線・距離と手順の文章が同じ計算から出る。
 * 3. 2D の代替は同じ角度の等角投影(SVG)。壁は1m以下に刻んで手前ほど後に描く(画家のアルゴリズム)と、
 *    長い壁でも前後の取り違えが起きにくい。viewBox も同じ範囲から決めるので、寄る動作も2Dで再現できる。
 * 4. 描画はオンデマンド。3Dライブラリは画面に入った実寸表示のときだけ動的 import し、縮小カード・WebGL非対応・
 *    コンテキスト喪失では 2D を出す。prefers-reduced-motion では補間せず最終状態を1枚だけ描く。
 * 5. 入力の正は一覧のボタン(aria-pressed)と前後ボタン。3D のクリックは近道、矢印キーで向きを変え Escape で全体へ戻る。
 *
 * 間取り・部屋名・設備はすべて First CH のオリジナル(見せ方を示すための見本)。
 */

type ThreeModule = typeof import("three");

const AMBER = 0xd97706;

/* ------------------------------------------------------------------ データ(単位は m) */

const PLAN_W = 16; // x
const PLAN_D = 10; // z(大きいほど手前=入口側)
const WALL_H = 0.9; // 模型として低く切った壁
const WALL_T = 0.1;
const DOOR_W = 0.9;
const CORRIDOR_Z0 = 4.5;
const CORRIDOR_Z1 = 6;
const CORRIDOR_MID = (CORRIDOR_Z0 + CORRIDOR_Z1) / 2;
const ENTRY: Pt = [1.75, 10.4];
const WALK_SPEED = 1.2; // m/s

type Pt = [number, number];
type RoomId = "meeting" | "talkA" | "talkB" | "booth" | "archive" | "restroom" | "reception" | "office" | "lounge" | "pantry";
type Box = { x: number; z: number; w: number; d: number; h: number };

type Room = {
  id: RoomId;
  no: string;
  name: string;
  use: string;
  seats: number;
  facilities: string[];
  x0: number;
  z0: number;
  x1: number;
  z1: number;
  /** 扉の x(廊下側の辺) */
  door: number;
  /** 廊下側の辺に壁を立てない(受付) */
  open?: boolean;
  furniture: Box[];
};

const ROOMS: Room[] = [
  {
    id: "meeting",
    no: "01",
    name: "会議室",
    use: "社内の定例と来客との打ち合わせ。Web会議の機材を常設しています。",
    seats: 10,
    facilities: ["大型モニター", "Web会議用スピーカー", "ホワイトボード"],
    x0: 0,
    z0: 0,
    x1: 5,
    z1: 4.5,
    door: 3.4,
    furniture: [
      { x: 2.5, z: 2.1, w: 3.2, d: 1.2, h: 0.72 },
      { x: 2.5, z: 0.2, w: 1.7, d: 0.08, h: 0.85 },
    ],
  },
  {
    id: "talkA",
    no: "02",
    name: "打ち合わせ室A",
    use: "短い打ち合わせ用。予約なしで空いていれば使えます。",
    seats: 4,
    facilities: ["モニター", "電源"],
    x0: 5,
    z0: 0,
    x1: 7.5,
    z1: 4.5,
    door: 6.25,
    furniture: [{ x: 6.25, z: 2.0, w: 1.2, d: 0.9, h: 0.72 }],
  },
  {
    id: "talkB",
    no: "03",
    name: "打ち合わせ室B",
    use: "立ったまま話せる高めの机。壁一面に書けます。",
    seats: 4,
    facilities: ["壁面ホワイトボード", "ハイテーブル"],
    x0: 7.5,
    z0: 0,
    x1: 10,
    z1: 4.5,
    door: 8.75,
    furniture: [{ x: 8.75, z: 2.0, w: 0.9, d: 1.4, h: 0.85 }],
  },
  {
    id: "booth",
    no: "04",
    name: "集中ブース",
    use: "電話・Web会議・一人作業のための仕切られた席。",
    seats: 3,
    facilities: ["吸音パネル", "USB電源"],
    x0: 10,
    z0: 0,
    x1: 13,
    z1: 4.5,
    door: 11.5,
    furniture: [
      { x: 10.5, z: 0.45, w: 0.8, d: 0.6, h: 0.72 },
      { x: 11.5, z: 0.45, w: 0.8, d: 0.6, h: 0.72 },
      { x: 12.5, z: 0.45, w: 0.8, d: 0.6, h: 0.72 },
      { x: 11, z: 0.85, w: 0.05, d: 1.6, h: 0.85 },
      { x: 12, z: 0.85, w: 0.05, d: 1.6, h: 0.85 },
    ],
  },
  {
    id: "archive",
    no: "05",
    name: "書庫",
    use: "契約書と図面の保管庫。扉は施錠しています。",
    seats: 0,
    facilities: ["施錠扉", "可動棚"],
    x0: 13,
    z0: 0,
    x1: 14.5,
    z1: 4.5,
    door: 13.75,
    furniture: [
      { x: 13.25, z: 1.9, w: 0.35, d: 3.0, h: 0.85 },
      { x: 14.25, z: 1.9, w: 0.35, d: 3.0, h: 0.85 },
    ],
  },
  {
    id: "restroom",
    no: "06",
    name: "化粧室",
    use: "男女共用。車いすで入れる広さです。",
    seats: 0,
    facilities: ["多目的トイレ", "手すり"],
    x0: 14.5,
    z0: 0,
    x1: 16,
    z1: 4.5,
    door: 15.25,
    furniture: [
      { x: 15.25, z: 0.8, w: 0.5, d: 0.7, h: 0.45 },
      { x: 15.65, z: 2.6, w: 0.45, d: 0.5, h: 0.85 },
    ],
  },
  {
    id: "reception",
    no: "07",
    name: "受付",
    use: "来客の受付と荷物の受け取り。建物の出入口はここだけです。",
    seats: 2,
    facilities: ["受付カウンター", "来客用ベンチ"],
    x0: 0,
    z0: 6,
    x1: 3.5,
    z1: 10,
    door: 1.75,
    open: true,
    furniture: [
      { x: 0.65, z: 7.6, w: 0.6, d: 2.0, h: 0.85 },
      { x: 3.0, z: 8.8, w: 0.45, d: 1.4, h: 0.45 },
    ],
  },
  {
    id: "office",
    no: "08",
    name: "執務エリア",
    use: "席を決めずに座る執務席。複合機は扉のそばにあります。",
    seats: 24,
    facilities: ["執務席24", "複合機", "ロッカー"],
    x0: 3.5,
    z0: 6,
    x1: 10.5,
    z1: 10,
    door: 7,
    furniture: [
      { x: 5, z: 8.1, w: 1.4, d: 2.6, h: 0.72 },
      { x: 7, z: 8.1, w: 1.4, d: 2.6, h: 0.72 },
      { x: 9, z: 8.1, w: 1.4, d: 2.6, h: 0.72 },
      { x: 10.05, z: 6.5, w: 0.6, d: 0.6, h: 0.8 },
      { x: 4.2, z: 9.7, w: 1.2, d: 0.4, h: 0.85 },
    ],
  },
  {
    id: "lounge",
    no: "09",
    name: "ラウンジ",
    use: "昼食と雑談の場所。説明会では椅子を並べて30人まで入れます。",
    seats: 12,
    facilities: ["可動テーブル", "プロジェクター"],
    x0: 10.5,
    z0: 6,
    x1: 14,
    z1: 10,
    door: 12.25,
    furniture: [
      { x: 11.5, z: 7.7, w: 1.0, d: 1.0, h: 0.72 },
      { x: 13.0, z: 7.7, w: 1.0, d: 1.0, h: 0.72 },
      { x: 12.25, z: 9.55, w: 2.6, d: 0.6, h: 0.45 },
    ],
  },
  {
    id: "pantry",
    no: "10",
    name: "給湯室",
    use: "コーヒーマシンと冷蔵庫。洗い物はここで。",
    seats: 0,
    facilities: ["冷蔵庫", "食洗機"],
    x0: 14,
    z0: 6,
    x1: 16,
    z1: 10,
    door: 15,
    furniture: [
      { x: 15.7, z: 8.2, w: 0.5, d: 3.2, h: 0.9 },
      { x: 14.35, z: 9.6, w: 0.6, d: 0.6, h: 0.85 },
    ],
  },
];

const ROOM = Object.fromEntries(ROOMS.map((r) => [r.id, r])) as Record<RoomId, Room>;
const isTop = (r: Room) => r.z1 <= CORRIDOR_Z0;
const area = (r: Room) => (r.x1 - r.x0) * (r.z1 - r.z0);
const center = (r: Room): Pt => [(r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2];
const TOTAL_SEATS = ROOMS.reduce((s, r) => s + r.seats, 0);
const fmt = (v: number) => (Math.round(v * 10) / 10).toFixed(1);

/* ------------------------------------------------------------------ 道順(3D・2D・文章の共通の正) */

type Route = { pts: Pt[]; steps: { text: string; len: number }[]; len: number };

const segLen = (a: Pt, b: Pt) => Math.hypot(b[0] - a[0], b[1] - a[1]);

function routeOf(r: Room): Route {
  let pts: Pt[];
  let steps: Route["steps"];
  if (r.open) {
    const end: Pt = [r.door, 8.1];
    pts = [ENTRY, end];
    steps = [{ text: "入口を入ってすぐ正面", len: segLen(ENTRY, end) }];
  } else {
    const top = isTop(r);
    const edge = top ? r.z1 : r.z0;
    const inside: Pt = [r.door, top ? edge - 1.0 : edge + 1.0];
    const a: Pt = [ENTRY[0], CORRIDOR_MID];
    const b: Pt = [r.door, CORRIDOR_MID];
    const c: Pt = [r.door, edge];
    pts = [ENTRY, a, b, c, inside];
    steps = [
      { text: "入口から受付を抜けて廊下へ", len: segLen(ENTRY, a) },
      { text: "廊下を右へ", len: segLen(a, b) },
      // 廊下を +x へ歩くと、奥(z が小さい側)が左手・手前が右手
      { text: `${top ? "左手" : "右手"}の扉から入る`, len: segLen(b, c) + segLen(c, inside) },
    ];
  }
  const len = pts.slice(1).reduce((s, p, i) => s + segLen(pts[i], p), 0);
  return { pts, steps, len };
}

const ROUTES = Object.fromEntries(ROOMS.map((r) => [r.id, routeOf(r)])) as Record<RoomId, Route>;

/* ------------------------------------------------------------------ 壁(部屋の矩形から生成) */

type Wall = { x0: number; z0: number; x1: number; z1: number };

function buildWalls(): Wall[] {
  const out = new Map<string, Wall>();
  const add = (w: Wall) => {
    if (Math.abs(w.x1 - w.x0) + Math.abs(w.z1 - w.z0) < 0.05) return;
    out.set([w.x0, w.z0, w.x1, w.z1].map((v) => v.toFixed(2)).join(","), w);
  };
  /** 横の辺を切り欠きつきで足す */
  const hEdge = (x0: number, x1: number, z: number, gap?: [number, number]) => {
    if (!gap) return add({ x0, z0: z, x1, z1: z });
    add({ x0, z0: z, x1: gap[0], z1: z });
    add({ x0: gap[1], z0: z, x1, z1: z });
  };
  for (const r of ROOMS) {
    const top = isTop(r);
    const doorGap: [number, number] = [r.door - DOOR_W / 2, r.door + DOOR_W / 2];
    // 奥の辺・手前の辺
    if (top) {
      hEdge(r.x0, r.x1, r.z0);
      hEdge(r.x0, r.x1, r.z1, doorGap);
    } else {
      if (!r.open) hEdge(r.x0, r.x1, r.z0, doorGap);
      hEdge(r.x0, r.x1, r.z1, r.open ? [ENTRY[0] - 0.6, ENTRY[0] + 0.6] : undefined);
    }
    add({ x0: r.x0, z0: r.z0, x1: r.x0, z1: r.z1 });
    add({ x0: r.x1, z0: r.z0, x1: r.x1, z1: r.z1 });
  }
  // 廊下の両端
  add({ x0: 0, z0: CORRIDOR_Z0, x1: 0, z1: CORRIDOR_Z1 });
  add({ x0: PLAN_W, z0: CORRIDOR_Z0, x1: PLAN_W, z1: CORRIDOR_Z1 });
  return [...out.values()];
}

const WALLS = buildWalls();

/** 壁を箱にする。横の壁は厚み分だけ伸ばし、縦の壁は厚み分だけ縮めて角で重ならないようにする */
function wallBox(w: Wall): Box {
  const horiz = w.z0 === w.z1;
  if (horiz) return { x: (w.x0 + w.x1) / 2, z: w.z0, w: Math.abs(w.x1 - w.x0) + WALL_T, d: WALL_T, h: WALL_H };
  return { x: w.x0, z: (w.z0 + w.z1) / 2, w: WALL_T, d: Math.max(0.01, Math.abs(w.z1 - w.z0) - WALL_T), h: WALL_H };
}

/* ------------------------------------------------------------------ 見せる範囲 */

type Region = [number, number, number, number]; // x0,z0,x1,z1
const OVERVIEW: Region = [-0.3, -0.3, PLAN_W + 0.3, PLAN_D + 0.7];

function regionOf(id: RoomId | null): Region {
  if (!id) return OVERVIEW;
  const r = ROOM[id];
  // 部屋と、扉の先の廊下まで少し含める
  const pad = 0.7;
  return [r.x0 - pad, Math.min(r.z0, CORRIDOR_Z0) - pad, r.x1 + pad, Math.max(r.z1, CORRIDOR_Z1) + pad];
}

/* ------------------------------------------------------------------ 3D */

type ViewerApi = {
  setSelected: (id: RoomId | null) => void;
  nudge: (dYaw: number, dElev: number) => void;
  recenter: () => void;
  setRunning: (running: boolean) => void;
  dispose: () => void;
};

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

const YAW0 = Math.PI / 4;
const ELEV0 = 0.78;

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();
  const camera = new T.OrthographicCamera(-1, 1, 1, -1, 0.1, 120);

  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.Material[] = [];
  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(6, 14, 9);
  scene.add(key);
  const fill = new T.DirectionalLight(0xfff4e6, 0.45);
  fill.position.set(-8, 6, -4);
  scene.add(fill);

  const unitBox = keepG(new T.BoxGeometry(1, 1, 1));
  const unitEdges = keepG(new T.EdgesGeometry(unitBox));
  const edgeMat = keepM(new T.LineBasicMaterial({ color: 0x78716c, transparent: true, opacity: 0.5 }));
  const wallMat = keepM(new T.MeshStandardMaterial({ color: 0xfbfaf8, roughness: 0.9 }));
  const furnMat = keepM(new T.MeshStandardMaterial({ color: 0xd6d3d1, roughness: 0.85 }));
  const floorMat = keepM(new T.MeshStandardMaterial({ color: 0xf2efe9, roughness: 0.95 }));
  const floorPick = keepM(new T.MeshStandardMaterial({ color: 0xf6d3a1, roughness: 0.95 }));
  const corridorMat = keepM(new T.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95 }));
  const baseMat = keepM(new T.MeshStandardMaterial({ color: 0xe7e5e4, roughness: 1 }));
  const routeMat = keepM(new T.MeshBasicMaterial({ color: AMBER, toneMapped: false }));
  const pickEdgeMat = keepM(new T.LineBasicMaterial({ color: AMBER, toneMapped: false }));

  const boxMesh = (b: Box, mat: THREE.Material, y0 = 0, edges = true) => {
    const m = new T.Mesh(unitBox, mat);
    m.scale.set(b.w, b.h, b.d);
    m.position.set(b.x, y0 + b.h / 2, b.z);
    if (edges) m.add(new T.LineSegments(unitEdges, edgeMat));
    scene.add(m);
    return m;
  };

  // 台座・廊下・部屋の床
  boxMesh({ x: PLAN_W / 2, z: PLAN_D / 2, w: PLAN_W + 0.6, d: PLAN_D + 0.6, h: 0.16 }, baseMat, -0.16, false);
  boxMesh({ x: PLAN_W / 2, z: CORRIDOR_MID, w: PLAN_W, d: CORRIDOR_Z1 - CORRIDOR_Z0, h: 0.03 }, corridorMat, 0, false);
  const floors = {} as Record<RoomId, THREE.Mesh>;
  const outlines = {} as Record<RoomId, THREE.LineSegments>;
  for (const r of ROOMS) {
    const [cx, cz] = center(r);
    const f = boxMesh({ x: cx, z: cz, w: r.x1 - r.x0, d: r.z1 - r.z0, h: 0.03 }, floorMat, 0, false);
    f.userData.room = r.id;
    floors[r.id] = f;
    // 選択中の部屋の床の縁(アンバー)
    const line = new T.LineSegments(unitEdges, pickEdgeMat);
    line.scale.set(1.002, 1.4, 1.002);
    line.visible = false;
    f.add(line);
    outlines[r.id] = line;
    for (const b of r.furniture) boxMesh(b, furnMat, 0.03);
  }
  for (const w of WALLS) boxMesh(wallBox(w), wallMat, 0.03);

  // 道順の帯(区間ごとに単位箱を伸ばして使い回す)
  const routeGroup = new T.Group();
  scene.add(routeGroup);
  const segs: THREE.Mesh[] = [];
  const startMark = new T.Mesh(unitBox, routeMat);
  startMark.scale.set(0.42, 0.03, 0.42);
  const endMark = new T.Mesh(keepG(new T.CylinderGeometry(0.24, 0.24, 0.05, 40)), routeMat);
  routeGroup.add(startMark, endMark);

  /* --- 状態 --- */
  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 selected = opts.selected;
  let regionTarget = regionOf(selected);
  let region: Region = motionOK && selected ? [...OVERVIEW] : [...regionTarget];
  let yaw = YAW0;
  let elev = ELEV0;
  let yawTarget = YAW0;
  let elevTarget = ELEV0;
  let width = 1;
  let height = 1;

  const applySelection = () => {
    for (const r of ROOMS) {
      const on = r.id === selected;
      floors[r.id].material = on ? floorPick : floorMat;
      outlines[r.id].visible = on;
    }
    routeGroup.visible = !!selected;
    if (!selected) return;
    const pts = ROUTES[selected].pts;
    for (let i = 0; i < pts.length - 1; i++) {
      let s = segs[i];
      if (!s) {
        s = new T.Mesh(unitBox, routeMat);
        routeGroup.add(s);
        segs.push(s);
      }
      const [ax, az] = pts[i];
      const [bx, bz] = pts[i + 1];
      const len = Math.hypot(bx - ax, bz - az);
      s.visible = true;
      s.scale.set(len + 0.13, 0.025, 0.13);
      s.position.set((ax + bx) / 2, 0.06, (az + bz) / 2);
      s.rotation.y = -Math.atan2(bz - az, bx - ax);
    }
    for (let i = pts.length - 1; i < segs.length; i++) segs[i].visible = false;
    startMark.position.set(pts[0][0], 0.06, pts[0][1]);
    const end = pts[pts.length - 1];
    endMark.position.set(end[0], 0.07, end[1]);
  };

  const corners = Array.from({ length: 8 }, () => new T.Vector3());
  const placeCamera = () => {
    const [x0, z0, x1, z1] = region;
    const cx = (x0 + x1) / 2;
    const cz = (z0 + z1) / 2;
    const dir = new T.Vector3(Math.sin(yaw) * Math.cos(elev), Math.sin(elev), Math.cos(yaw) * Math.cos(elev));
    camera.position.set(cx, 0.4, cz).addScaledVector(dir, 40);
    camera.lookAt(cx, 0.4, cz);
    camera.updateMatrixWorld();
    // 範囲の8隅をカメラ空間へ写し、それが収まる左右上下を決める
    let minX = Infinity;
    let maxX = -Infinity;
    let minY = Infinity;
    let maxY = -Infinity;
    let i = 0;
    for (const x of [x0, x1]) for (const z of [z0, z1]) for (const y of [0, WALL_H]) corners[i++].set(x, y, z);
    for (const c of corners) {
      c.applyMatrix4(camera.matrixWorldInverse);
      minX = Math.min(minX, c.x);
      maxX = Math.max(maxX, c.x);
      minY = Math.min(minY, c.y);
      maxY = Math.max(maxY, c.y);
    }
    let hw = ((maxX - minX) / 2) * 1.04;
    let hh = ((maxY - minY) / 2) * 1.04;
    const aspect = width / height;
    if (hw / hh > aspect) hh = hw / aspect;
    else hw = hh * aspect;
    const mx = (minX + maxX) / 2;
    const my = (minY + maxY) / 2;
    camera.left = mx - hw;
    camera.right = mx + hw;
    camera.top = my + hh;
    camera.bottom = my - hh;
    camera.updateProjectionMatrix();
  };

  const anchor = new T.Vector3();
  const placeLabels = () => {
    ROOMS.forEach((r, i) => {
      const el = labels[i];
      if (!el) return;
      const [cx, cz] = center(r);
      anchor.set(cx, WALL_H + 0.25, cz).project(camera);
      const inside = Math.abs(anchor.x) < 1.02 && Math.abs(anchor.y) < 1.02;
      const x = ((anchor.x + 1) / 2) * width;
      const y = ((1 - anchor.y) / 2) * height;
      el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -100%)`;
      el.style.opacity = inside ? "1" : "0";
    });
  };

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

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

  const settle = () => {
    region = [...regionTarget];
    yaw = yawTarget;
    elev = elevTarget;
    renderOnce();
  };

  function stop() {
    if (!raf) return;
    cancelAnimationFrame(raf);
    raf = 0;
  }

  const frameLoop = (now: number) => {
    raf = requestAnimationFrame(frameLoop);
    if (!last) last = now;
    const dt = Math.min((now - last) / 1000, 0.06);
    last = now;
    const k = 1 - Math.pow(0.006, dt);
    let diff = 0;
    for (let i = 0; i < 4; i++) {
      region[i] += (regionTarget[i] - region[i]) * k;
      diff = Math.max(diff, Math.abs(regionTarget[i] - region[i]));
    }
    yaw += (yawTarget - yaw) * k;
    elev += (elevTarget - elev) * k;
    if (diff < 0.004 && Math.abs(yawTarget - yaw) < 1e-3 && Math.abs(elevTarget - elev) < 1e-3) {
      stop();
      settle();
      return;
    }
    renderOnce();
  };

  /** 変化があったときに呼ぶ。動かせない状況なら最終状態を1枚だけ描く */
  const kick = () => {
    if (!running || !motionOK) {
      stop();
      settle();
      return;
    }
    if (raf) return;
    last = 0;
    raf = requestAnimationFrame(frameLoop);
  };

  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    kick();
  };
  motionQuery.addEventListener("change", onMotionChange);

  /* --- 操作: クリックで部屋を選ぶ・ドラッグで向きを変える --- */
  const raycaster = new T.Raycaster();
  const ndc = new T.Vector2();
  const floorList = Object.values(floors);
  const pickAt = (clientX: number, clientY: number): RoomId | null => {
    const rect = canvas.getBoundingClientRect();
    ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
    raycaster.setFromCamera(ndc, camera);
    const hit = raycaster.intersectObjects(floorList, false)[0];
    return (hit?.object.userData.room as RoomId | undefined) ?? null;
  };
  const clampYaw = (v: number) => Math.max(YAW0 - 0.65, Math.min(YAW0 + 0.65, v));
  const clampElev = (v: number) => Math.max(0.42, Math.min(1.25, v));

  let pointerId: number | null = null;
  let downX = 0;
  let downY = 0;
  let lastX = 0;
  let lastY = 0;
  let dragged = false;
  const onPointerDown = (e: PointerEvent) => {
    if (e.button !== 0) return;
    pointerId = e.pointerId;
    downX = lastX = e.clientX;
    downY = lastY = e.clientY;
    dragged = false;
    canvas.setPointerCapture(e.pointerId);
  };
  const onPointerMove = (e: PointerEvent) => {
    if (pointerId === null) {
      if (e.pointerType === "mouse") canvas.style.cursor = pickAt(e.clientX, e.clientY) ? "pointer" : "grab";
      return;
    }
    if (e.pointerId !== pointerId) return;
    if (!dragged && Math.hypot(e.clientX - downX, e.clientY - downY) < 5) return;
    dragged = true;
    canvas.style.cursor = "grabbing";
    const k = 2.2 / Math.max(width, 1);
    yawTarget = yaw = clampYaw(yawTarget - (e.clientX - lastX) * k);
    elevTarget = elev = clampElev(elevTarget + (e.clientY - lastY) * k);
    lastX = e.clientX;
    lastY = e.clientY;
    if (!raf) renderOnce();
  };
  const onPointerUp = (e: PointerEvent) => {
    if (e.pointerId !== pointerId) return;
    pointerId = null;
    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();
    renderOnce();
  });
  ro.observe(host);
  applySelection();
  resize();
  renderOnce();

  return {
    setSelected: (id) => {
      selected = id;
      regionTarget = regionOf(id);
      applySelection();
      kick();
    },
    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 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 g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ 2D(等角投影の SVG) */

const S = 30; // 1m あたりの SVG 単位
const SIN_E = Math.sin(ELEV0);
const COS_E = Math.cos(ELEV0);
const C45 = Math.SQRT1_2;
/** 3D の既定の向き(yaw 45°・仰角 ELEV0)と同じ正射影 */
const iso = (x: number, z: number, y = 0): Pt => [(x - z) * C45 * S, ((x + z) * C45 * SIN_E - y * COS_E) * S];
const poly = (pts: Pt[]) => pts.map(([a, b]) => `${a.toFixed(1)},${b.toFixed(1)}`).join(" ");

function quad(x0: number, z0: number, x1: number, z1: number, y: number) {
  return poly([iso(x0, z0, y), iso(x1, z0, y), iso(x1, z1, y), iso(x0, z1, y)]);
}

type Solid = { b: Box; kind: "wall" | "furn"; key: number };

/** 壁は 1m 以下に刻み、手前(x+z が大きい)ほど後に描く */
const SOLIDS: Solid[] = (() => {
  const out: Solid[] = [];
  for (const w of WALLS) {
    const b = wallBox(w);
    const along = b.w > b.d ? "x" : "z";
    const len = along === "x" ? b.w : b.d;
    const n = Math.max(1, Math.ceil(len / 1));
    for (let i = 0; i < n; i++) {
      const a = -len / 2 + (len * i) / n;
      const piece = len / n + (i < n - 1 ? 0.03 : 0); // 継ぎ目が見えないよう少し重ねる
      const c = a + piece / 2;
      out.push({
        kind: "wall",
        b: along === "x" ? { ...b, x: b.x + c, w: piece } : { ...b, z: b.z + c, d: piece },
        key: 0,
      });
    }
  }
  for (const r of ROOMS) for (const b of r.furniture) out.push({ kind: "furn", b, key: 0 });
  for (const s of out) s.key = s.b.x + s.b.z;
  return out.sort((p, q) => p.key - q.key);
})();

function regionViewBox(reg: Region) {
  const [x0, z0, x1, z1] = reg;
  const pts = [x0, x1].flatMap((x) => [z0, z1].flatMap((z) => [iso(x, z, 0), iso(x, z, WALL_H)]));
  const xs = pts.map((p) => p[0]);
  const ys = pts.map((p) => p[1]);
  const pad = 0.4 * S;
  const minX = Math.min(...xs) - pad;
  const minY = Math.min(...ys) - pad;
  return `${minX.toFixed(1)} ${minY.toFixed(1)} ${(Math.max(...xs) - minX + pad).toFixed(1)} ${(Math.max(...ys) - minY + pad).toFixed(1)}`;
}

function IsoPlan({ selected, zoom }: { selected: RoomId | null; zoom: boolean }) {
  const route = selected ? ROUTES[selected] : null;
  return (
    <svg viewBox={regionViewBox(regionOf(zoom ? selected : null))} preserveAspectRatio="xMidYMid meet" className="block size-full" aria-hidden="true">
      {/* 台座の側面と上面 */}
      <polygon points={poly([iso(-0.3, PLAN_D + 0.3, 0), iso(PLAN_W + 0.3, PLAN_D + 0.3, 0), iso(PLAN_W + 0.3, PLAN_D + 0.3, -0.16), iso(-0.3, PLAN_D + 0.3, -0.16)])} fill="#d6d3d1" />
      <polygon points={poly([iso(PLAN_W + 0.3, -0.3, 0), iso(PLAN_W + 0.3, PLAN_D + 0.3, 0), iso(PLAN_W + 0.3, PLAN_D + 0.3, -0.16), iso(PLAN_W + 0.3, -0.3, -0.16)])} fill="#c4bfba" />
      <polygon points={quad(-0.3, -0.3, PLAN_W + 0.3, PLAN_D + 0.3, 0)} fill="#e7e5e4" />
      <polygon points={quad(0, CORRIDOR_Z0, PLAN_W, CORRIDOR_Z1, 0)} fill="#ffffff" />
      {ROOMS.map((r) => (
        <polygon
          key={r.id}
          points={quad(r.x0, r.z0, r.x1, r.z1, 0)}
          fill={r.id === selected ? "#f6d3a1" : "#f2efe9"}
          stroke={r.id === selected ? "#d97706" : "none"}
          strokeWidth={r.id === selected ? 1.6 : 0}
        />
      ))}
      {route && (
        <g>
          <polyline points={poly(route.pts.map(([x, z]) => iso(x, z, 0.05)))} fill="none" stroke="#d97706" strokeWidth={3.6} strokeLinejoin="round" strokeLinecap="round" />
          <polygon points={quad(route.pts[0][0] - 0.21, route.pts[0][1] - 0.21, route.pts[0][0] + 0.21, route.pts[0][1] + 0.21, 0.05)} fill="#d97706" />
          {(() => {
            const [ex, ez] = iso(...route.pts[route.pts.length - 1], 0.05);
            return <ellipse cx={ex} cy={ez} rx={0.24 * S} ry={0.24 * S * SIN_E} fill="#d97706" />;
          })()}
        </g>
      )}
      {SOLIDS.map((s, i) => {
        const { x, z, w, d, h } = s.b;
        const y0 = 0.03;
        const ax = x - w / 2;
        const bx = x + w / 2;
        const az = z - d / 2;
        const bz = z + d / 2;
        const top = s.kind === "wall" ? "#ffffff" : "#e7e5e4";
        const front = s.kind === "wall" ? "#e7e5e4" : "#cfcac4";
        const side = s.kind === "wall" ? "#d6d3d1" : "#b9b3ac";
        const stroke = s.kind === "furn" ? { stroke: "#a8a29e", strokeWidth: 0.5 } : {};
        return (
          <g key={i}>
            <polygon points={poly([iso(ax, bz, y0), iso(bx, bz, y0), iso(bx, bz, y0 + h), iso(ax, bz, y0 + h)])} fill={front} {...stroke} />
            <polygon points={poly([iso(bx, az, y0), iso(bx, bz, y0), iso(bx, bz, y0 + h), iso(bx, az, y0 + h)])} fill={side} {...stroke} />
            <polygon points={quad(ax, az, bx, bz, y0 + h)} fill={top} {...stroke} />
          </g>
        );
      })}
      {ROOMS.map((r) => {
        const [lx, ly] = iso(...center(r), WALL_H + 0.25);
        const on = r.id === selected;
        return (
          <g key={r.id} className={montserrat.className}>
            <rect x={lx - 11} y={ly - 15} width={22} height={13} fill={on ? "#d97706" : "#ffffff"} stroke={on ? "#d97706" : "#a8a29e"} strokeWidth={0.6} />
            <text x={lx} y={ly - 5.6} textAnchor="middle" fontSize={8.5} fontWeight={700} fill={on ? "#ffffff" : "#44403c"}>
              {r.no}
            </text>
          </g>
        );
      })}
    </svg>
  );
}

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

const ROT = (14 * Math.PI) / 180;

export default function IsometricFloorMap() {
  const uid = useId().replace(/:/g, "");
  const headingId = `floor-heading-${uid}`;
  const hintId = `floor-hint-${uid}`;
  const detailId = `floor-detail-${uid}`;

  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const labelRefs = useRef<(HTMLElement | null)[]>([]);
  const apiRef = useRef<ViewerApi | null>(null);

  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<RoomId | null>(null);
  const [announce, setAnnounce] = useState("");

  const initialRef = useRef<RoomId | null>(null);
  useEffect(() => {
    initialRef.current = selected;
  }, [selected]);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に 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;
    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);
    };
  }, []);

  useEffect(() => {
    if (!live) return;
    const canvas = canvasRef.current;
    const host = stageRef.current;
    if (!canvas || !host) return;
    let cancelled = false;
    const boot = async () => {
      // 非対応なら重いライブラリを取りに行かず、2D のまま確定させる
      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,
        selected: initialRef.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]);

  // 選択が落ち着いてから読み上げる
  useEffect(() => {
    const t = window.setTimeout(() => {
      if (!selected) {
        setAnnounce("フロア全体を表示しています");
        return;
      }
      const r = ROOM[selected];
      const route = ROUTES[selected];
      setAnnounce(`${r.no} ${r.name}。入口から${fmt(route.len)}メートル、歩いて約${Math.round(route.len / WALK_SPEED)}秒`);
    }, 350);
    return () => window.clearTimeout(t);
  }, [selected]);

  const step = (dir: 1 | -1) => {
    const i = selected ? ROOMS.findIndex((r) => r.id === selected) : dir === 1 ? -1 : ROOMS.length;
    setSelected(ROOMS[(i + dir + ROOMS.length) % ROOMS.length].id);
  };

  const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape") {
      e.preventDefault();
      setSelected(null);
      return;
    }
    const api = apiRef.current;
    if (!api) return;
    const moves: Record<string, [number, number]> = {
      ArrowLeft: [ROT, 0],
      ArrowRight: [-ROT, 0],
      ArrowUp: [0, ROT / 2],
      ArrowDown: [0, -ROT / 2],
    };
    if (e.key in moves) {
      e.preventDefault();
      api.nudge(...moves[e.key]);
    } else if (e.key === "Home") {
      e.preventDefault();
      api.recenter();
    }
  };

  const show3d = ready && !failed;
  const room = selected ? ROOM[selected] : null;
  const route = selected ? ROUTES[selected] : null;
  const btn =
    "cursor-pointer border border-gray-300 bg-white px-3 py-1.5 text-[12px] font-bold text-gray-700 transition-colors hover:border-amber-600 hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:cursor-default disabled:border-gray-200 disabled:text-gray-400 motion-reduce:transition-none";

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 冒頭: 英字の小見出しは置かず、見出しとフロアの概要で開く */}
      <div className="grid gap-6 @2xl:grid-cols-[minmax(0,1fr)_auto] @2xl:items-end">
        <div>
          <h2 id={headingId} className="text-[clamp(1.35rem,3.4vw,2.05rem)] leading-[1.35] font-bold tracking-tight">
            初めて来た人が、
            <span className="whitespace-nowrap">迷わず部屋へ着く</span>
          </h2>
          <p className="mt-4 max-w-xl text-[14px] leading-[1.9] font-medium text-gray-600">
            フロアを斜め上から見た模型で示します。部屋を選ぶとその区画へ寄り、入口からの道順と距離、広さと設備を並べます。一覧からも同じ部屋を選べます。
          </p>
        </div>
        <dl className="grid grid-cols-3 border-y border-gray-200 @2xl:w-[19rem]">
          {[
            { term: "床面積", value: String(PLAN_W * PLAN_D), unit: "m²" },
            { term: "部屋", value: String(ROOMS.length), unit: "室" },
            { term: "席", value: String(TOTAL_SEATS), unit: "席" },
          ].map((s, i) => (
            <div key={s.term} className={`py-3 ${i > 0 ? "border-l border-gray-200 pl-3" : ""}`}>
              <dt className="text-[11px] font-bold tracking-wide whitespace-nowrap text-gray-500">{s.term}</dt>
              <dd className="mt-1 flex items-baseline gap-1 whitespace-nowrap">
                <span className={`${montserrat.className} text-[1.3rem] leading-none font-semibold tabular-nums`}>{s.value}</span>
                <span className="text-[11px] text-gray-500">{s.unit}</span>
              </dd>
            </div>
          ))}
        </dl>
      </div>

      <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @3xl:grid-cols-[minmax(0,1fr)_18rem] @3xl:gap-9">
        <div className="min-w-0">
          <div
            ref={stageRef}
            tabIndex={show3d ? 0 : -1}
            role="group"
            aria-roledescription="3Dフロアマップ"
            aria-label={room ? `フロアマップ。${room.name}に寄って表示中` : "フロアマップ。フロア全体を表示中"}
            aria-describedby={hintId}
            onKeyDown={onStageKey}
            className="relative aspect-[4/3] w-full overflow-hidden border border-gray-200 bg-[radial-gradient(60%_60%_at_50%_55%,rgba(217,119,6,0.05)_0%,rgba(255,255,255,0)_70%),linear-gradient(180deg,#ffffff_0%,#f7f5f1_100%)] @md:aspect-[16/10] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          >
            {/* 等角投影の2D: 3Dが立ち上がるまで・縮小プレビュー・WebGL非対応のときの表示。選択と寄りも同じ */}
            <div className={`absolute inset-0 p-3 transition-opacity duration-700 motion-reduce:transition-none ${show3d ? "pointer-events-none opacity-0" : "opacity-100"}`}>
              <IsoPlan selected={selected} zoom={failed} />
              {failed && (
                <p className="absolute bottom-2.5 left-3 bg-white/85 px-1.5 text-[10.5px] leading-[1.6] text-gray-500">3Dが使えない環境のため、同じ角度の図で表示中</p>
              )}
            </div>

            <canvas
              ref={canvasRef}
              aria-hidden="true"
              className={`absolute inset-0 block size-full cursor-grab touch-pan-y transition-opacity duration-700 motion-reduce:transition-none ${show3d ? "opacity-100" : "pointer-events-none opacity-0"}`}
            />

            {/* 部屋番号の札(位置は3D側が transform で書き換える) */}
            <div aria-hidden="true" className={`pointer-events-none absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none ${show3d ? "opacity-100" : "opacity-0"}`}>
              {ROOMS.map((r, i) => {
                const on = r.id === selected;
                return (
                  <span
                    key={r.id}
                    ref={(el) => {
                      labelRefs.current[i] = el;
                    }}
                    className={`absolute top-0 left-0 border px-1.5 py-0.5 text-[10.5px] leading-none font-bold whitespace-nowrap opacity-0 ${
                      on ? "border-amber-600 bg-amber-600 text-white" : "border-gray-300 bg-white/90 text-gray-700"
                    }`}
                  >
                    <span className={`${montserrat.className} tabular-nums`}>{r.no}</span>
                    {on && <span className="ml-1">{r.name}</span>}
                  </span>
                );
              })}
            </div>

            <p
              id={hintId}
              className={`pointer-events-none absolute bottom-2.5 left-3 bg-white/85 px-1.5 text-[10.5px] leading-[1.6] text-gray-500 transition-opacity duration-500 motion-reduce:transition-none ${show3d ? "opacity-100" : "opacity-0"}`}
            >
              床をクリックで部屋を選択・ドラッグで回転
              <span className="sr-only @md:not-sr-only">(矢印キーで回転・Escで全体)</span>
            </p>
          </div>

          <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
            <p className="text-[12px] font-bold text-gray-500">
              表示中:
              <span className="ml-1.5 text-gray-900">{room ? `${room.no} ${room.name}` : "フロア全体"}</span>
            </p>
            <div className="flex gap-2">
              <button type="button" className={btn} onClick={() => step(-1)}>
                前の部屋
              </button>
              <button type="button" className={btn} onClick={() => step(1)}>
                次の部屋
              </button>
              <button type="button" className={btn} onClick={() => setSelected(null)} disabled={!selected}>
                全体を見る
              </button>
            </div>
          </div>

          {/* 選んだ部屋の説明 */}
          <div id={detailId} className="mt-6 border-t border-gray-200 pt-5">
            {room && route ? (
              <div className="grid gap-6 @xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
                <div>
                  <h3 className="text-[1.1rem] leading-[1.4] font-bold">
                    <span className={`${montserrat.className} mr-2 text-[12px] font-semibold text-amber-700 tabular-nums`}>{room.no}</span>
                    {room.name}
                  </h3>
                  <p className="mt-2 text-[13px] leading-[1.85] text-gray-600">{room.use}</p>
                  <dl className="mt-4 grid grid-cols-3 border-y border-gray-200">
                    {[
                      { term: "広さ", value: fmt(area(room)), unit: "m²" },
                      { term: "席", value: room.seats ? String(room.seats) : "—", unit: room.seats ? "席" : "" },
                      { term: "入口から", value: fmt(route.len), unit: "m" },
                    ].map((s, i) => (
                      <div key={s.term} className={`py-2.5 ${i > 0 ? "border-l border-gray-200 pl-3" : ""}`}>
                        <dt className="text-[11px] font-bold text-gray-500">{s.term}</dt>
                        <dd className="mt-1 flex items-baseline gap-1 whitespace-nowrap">
                          <span className={`${montserrat.className} text-[1.15rem] leading-none font-semibold tabular-nums`}>{s.value}</span>
                          <span className="text-[11px] text-gray-500">{s.unit}</span>
                        </dd>
                      </div>
                    ))}
                  </dl>
                  <p className="mt-3 text-[12px] text-gray-500">設備: {room.facilities.join("、")}</p>
                </div>
                <div>
                  <p className="text-[12px] font-bold tracking-wide text-gray-500">入口からの道順(歩いて約{Math.round(route.len / WALK_SPEED)}秒)</p>
                  <ol className="mt-2 border-t border-gray-200">
                    {route.steps.map((s, i) => (
                      <li key={s.text} className="grid grid-cols-[1.6rem_minmax(0,1fr)_auto] items-baseline border-b border-gray-200 py-2 text-[13px]">
                        <span className={`${montserrat.className} text-[11px] font-semibold text-amber-700 tabular-nums`}>{i + 1}</span>
                        <span>{s.text}</span>
                        <span className={`${montserrat.className} text-[12px] font-medium text-gray-600 tabular-nums`}>{fmt(s.len)} m</span>
                      </li>
                    ))}
                  </ol>
                </div>
              </div>
            ) : (
              <p className="text-[13px] leading-[1.85] text-gray-600">
                部屋を選ぶと、入口からの道順・広さ・設備をここに出します。地図の床を押すか、一覧から選んでください。
              </p>
            )}
          </div>
        </div>

        {/* 部屋の一覧(入力の正) */}
        <div className="min-w-0 @3xl:border-l @3xl:border-gray-200 @3xl:pl-9">
          <p className="text-[12px] font-bold tracking-wide text-gray-500">部屋の一覧</p>
          <ol className="mt-3 border-t border-gray-200">
            {ROOMS.map((r) => {
              const on = r.id === selected;
              return (
                <li key={r.id} className="border-b border-gray-200">
                  <button
                    type="button"
                    aria-pressed={on}
                    aria-controls={detailId}
                    onClick={() => setSelected(on ? null : r.id)}
                    className={`grid w-full cursor-pointer grid-cols-[1.7rem_minmax(0,1fr)_auto] items-baseline gap-x-2 px-1 py-[0.55rem] text-left transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                      on ? "bg-amber-50/80" : "hover:bg-gray-50"
                    }`}
                  >
                    <span className={`${montserrat.className} text-[11px] font-semibold tabular-nums ${on ? "text-amber-700" : "text-gray-500"}`}>{r.no}</span>
                    <span className={`min-w-0 truncate text-[13px] ${on ? "font-bold text-amber-800" : "font-medium"}`}>{r.name}</span>
                    <span className={`${montserrat.className} text-right text-[11px] font-medium whitespace-nowrap text-gray-600 tabular-nums`}>
                      {fmt(area(r))} m²
                      <span className="ml-2.5 inline-block w-[3.3rem] text-gray-900">{fmt(ROUTES[r.id].len)} m</span>
                    </span>
                  </button>
                </li>
              );
            })}
          </ol>
          <p className="mt-2 text-[11px] leading-[1.7] text-gray-500">数値は部屋の広さと、入口から歩く距離です。選んだ部屋をもう一度押すと全体に戻ります</p>

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

      <p className="mt-8 border-t border-gray-200 pt-6 text-[12px] leading-[1.9] text-gray-500">
        間取り・部屋名・設備は見せ方を示すための見本です。部屋を矩形と扉の位置で書き換えれば、壁・道順・距離は同じ計算から作られます。
      </p>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/isometric-floor-map.json