BLK-91Blocks

Isometric Floor Map (pick a room to zoom in and read about it)

A 3D floor map that shows an office, shop or exhibition floor as an isometric model seen from above at an angle. Pick a room and the camera moves in on it, draws the route from the entrance on the floor, and lists the walking distance and time, step-by-step directions, size, seats and equipment. The sample is a 16 x 10 m office (ten rooms, a corridor and reception); each room is written only as a rectangle plus a door position, and walls are generated from the four edges with door openings cut out and shared edges de-duplicated. The route is a polyline (entrance, corridor centre line, door, inside the room), so the 3D ribbon, the 2D line, the distance and the written steps all come from the same calculation. Zooming tweens the floor region to show and projects its eight corners into camera space to set the orthographic frustum, so the chosen room fits at any aspect ratio or angle. The room list (aria-pressed buttons) and the previous/next/whole-floor buttons are the source of truth, so everything works by keyboard; in 3D you can click a floor to pick a room, drag or use the arrow keys to turn the view, and press Escape to return to the whole floor. 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 an isometric SVG drawn at the same angle with the same selection and route. With prefers-reduced-motion the camera is not tweened and each change draws the final state once. No small English kickers, trailing arrow links, rounded corners or scroll-in effects, just hairline rules and whitespace. Floor plan and equipment are sample data.

Added:
2026-10-06
Dependencies:
three
tags
#3d #floor-map #floor-plan #isometric #wayfinding #webgl #orthographic-camera #data-visualization #interactive #keyboard #no-image #dynamic-import

Preview

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

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

床面積
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>
  );
}

Add to your project via shadcn CLI

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