フロアマップ(部屋を選ぶと寄って説明が出る)
事務所・店舗・展示会場のフロアを斜め上から見た立体の間取りで示す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