デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM+WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
間取り図の部屋にカーソルを合わせると、その部屋の床だけが少し持ち上がって明るいベージュに変わり、上に「LDK 18.5畳」のような名前と広さのラベルが中央から左右へ開きます。左の一覧でも、同じ部屋の行の背景色が変わります。その間も、図全体はゆっくり左右へ向きを変え続けています。
どの部屋に重なっているかは、Raycaster でポインタから線を飛ばして決めています。線を当てるのは床のメッシュではなく、床の上面の高さに置いた見えない平面(THREE.Plane)で、当たった点の座標を部屋ごとの四角と比べます。持ち上がった床そのものに当てると、床が上がるたびに当たる点が手前へずれます。部屋の境目でカーソルを止めていても、隣り合う部屋が交互に上がったり下がったりしてしまうのです。
部屋ごとに持つ値は、持ち上がり(0〜1)の1つだけ。床の高さと色、ラベルの開き具合、一覧の行の背景色は、すべてこの値から描いています。上がるときは0.45秒の power2.out、下がるときは0.4秒の power2.inOut です。持ち上がる高さは壁より低い0.5mに抑えて、部屋が壁の内側からせり上がるように見せました。壁より高く上げれば部屋は目立ちますが、壁とのつながりが切れて、間取り図らしさが薄れます。
部屋の位置と名前・広さは HTML の一覧に書いてあり、スクリプトはそこから床とラベルを組み立てます。一覧の行はボタンなので、カーソルを重ねてもフォーカスしても同じ部屋が持ち上がり、押せばその部屋を選んだままにして aria-pressed で伝えます。ホバーのないタッチ端末では、図の部屋をタップしても選べます。立体のほうは一覧と同じ内容を描いているだけなので、スクリーンリーダーからは外しました。
コード
<section class="floorplan" aria-labelledby="floorplan-title">
<div class="floorplan__text">
<p class="floorplan__eyebrow">FLOOR PLAN</p>
<h2 class="floorplan__title" id="floorplan-title">間取り</h2>
<p class="floorplan__meta">2LDK・専有面積 68.4㎡</p>
<p class="floorplan__lead">ここに説明文が入ります。</p>
<!-- 部屋の位置は data-rect(左・奥・右・手前の端。メートル)。script.js がこの一覧から、持ち上がる床とラベルを組む -->
<ul class="floorplan__rooms">
<li><button type="button" aria-pressed="false" data-rect="0 2.6 6 7.6"><span>LDK</span><span class="floorplan__area">18.5畳</span></button></li>
<li><button type="button" aria-pressed="false" data-rect="6 4.36 9 7.6"><span>洋室</span><span class="floorplan__area">6.0畳</span></button></li>
<li><button type="button" aria-pressed="false" data-rect="0 0 3.1 2.6"><span>洋室</span><span class="floorplan__area">5.0畳</span></button></li>
<li><button type="button" aria-pressed="false" data-rect="7.4 2.34 9 4.36"><span>WIC</span><span class="floorplan__area">2.0畳</span></button></li>
</ul>
</div>
<!-- 立体は一覧と同じ内容を描くだけなので、スクリーンリーダーからは外す -->
<div class="floorplan__stage" aria-hidden="true"><canvas></canvas></div>
</section>
.floorplan {
/* 色はここで決める。script.js が --room から --ground までを読んで、立体の床・壁・光の色にする */
--bg: #f2f1ed;
--surface: #ffffff;
--ink: #262626;
--mute: #898989;
--mute-small: #6b6b6b; /* 16px 未満の文字 */
--line: rgba(38, 38, 38, 0.12);
--room: #d9c2a0; /* 持ち上がる部屋の床(淡いベージュ) */
--lifted: #f3e4ca; /* 持ち上がった床 */
--hall: #afa391; /* 廊下・玄関 */
--wet: #a7aeb2; /* 浴室・洗面室・トイレ */
--wall: #fbfaf7; /* 壁(白に近いオフホワイト) */
--base: #dad7d0; /* 図の下の台(背景色より少し暗いグレー) */
--ground: #c9c3b8; /* 下から回り込む光 */
--label-area: #6b6b6b; /* ラベルの中の広さの文字 */
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
align-items: center;
gap: 32px;
padding: 64px clamp(16px, 7vw, 96px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.floorplan__eyebrow { margin: 0; color: var(--mute-small); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; }
.floorplan__title { margin: 20px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.floorplan__meta { margin: 16px 0 0; color: var(--mute); font-size: 16px; font-weight: 600; line-height: 1.5; letter-spacing: 0.04em; }
.floorplan__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.floorplan__rooms { margin: 32px 0 0; padding: 0; border-top: 1px solid var(--line); list-style: none; }
/* --lift(0〜1)は script.js が入れる。部屋の床が持ち上がるのに合わせて、行の背景色と印が変わる */
.floorplan__rooms button {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
height: 48px;
padding: 0 12px;
border: 0;
box-shadow: inset 0 -1px 0 var(--line);
background: color-mix(in srgb, var(--surface) calc(var(--lift, 0) * 100%), transparent);
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.04em;
text-align: left;
cursor: pointer;
}
.floorplan__rooms button::before {
content: "";
flex: none;
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--room);
opacity: calc(0.35 + var(--lift, 0) * 0.65);
scale: calc(0.5 + var(--lift, 0) * 0.5);
}
.floorplan__rooms button > span:first-child { flex: 1; }
.floorplan__area { color: var(--mute-small); font-weight: 400; }
.floorplan__rooms button:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
/* 立体の表示領域。ラベルもこの中に置く */
.floorplan__stage { position: relative; aspect-ratio: 832 / 720; overflow: clip; }
.floorplan__stage canvas { display: block; width: 100%; height: 100%; }
/* 部屋のラベル。位置と開き具合(--open)は script.js が入れ、中央から左右へ開く */
.floorplan__label {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 16px;
border-radius: 999px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink);
font-size: 15px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.04em;
white-space: nowrap;
clip-path: inset(0 calc((1 - var(--open, 0)) * 50%) round 999px);
}
.floorplan__label .floorplan__area { color: var(--label-area); font-size: 14px; font-weight: 600; }
@media (max-width: 860px) {
.floorplan { grid-template-columns: minmax(0, 1fr); padding: 48px 16px; }
.floorplan__stage { aspect-ratio: 6 / 5; margin: 0 -16px; }
.floorplan__label { height: 30px; padding: 0 12px; font-size: 13px; }
.floorplan__label .floorplan__area { font-size: 12px; }
}
import * as THREE from "three";
import { gsap } from "gsap";
const W = 9, D = 7.6; // 間取り全体の幅と奥行き(メートル。x は左 → 右、z は奥 → 手前)
const SLAB = 0.12, LIFT = 0.5, WALL = 0.7, SILL = 0.22, OUTER = 0.16, INNER = 0.1; // 床の厚み・持ち上がる高さ・壁と窓の下の壁の高さ・外壁と内壁の厚み
const RISE = 0.45, FALL = 0.4; // 床が持ち上がる秒数(power2.out)と下がる秒数(power2.inOut)
const AZIMUTH = -0.22, SWAY = 0.09, PERIOD = 9.6, ELEVATION = 0.92, DISTANCE = 24; // カメラの向き・左右に振れる量・1 往復の秒数・見下ろす角度・距離
// 持ち上がらない床 [左, 奥, 右, 手前, 色の CSS 変数](浴室・洗面室・トイレ・廊下・玄関)
const FLOORS = [[3.1, 0, 4.9, 1.6, "--wet"], [3.1, 1.6, 4.9, 2.6, "--wet"], [4.9, 0, 6, 1.6, "--wet"], [6, 0, 7.4, 4.36, "--hall"], [4.9, 1.6, 6, 2.6, "--hall"], [7.4, 0, 9, 2.34, "--hall"]];
// 外壁 [x0, z0, x1, z1, 高さ] と内壁 [x0, z0, x1, z1]。すき間はドア、低い壁は窓の下
const OUTER_WALLS = [[0, 0, 7.75, 0, WALL], [8.65, 0, W, 0, WALL], [0, 0, 0, D, WALL], [W, 0, W, D, WALL], [0, D, 0.6, D, WALL], [0.6, D, 4.6, D, SILL], [4.6, D, 6.4, D, WALL], [6.4, D, 8.6, D, SILL], [8.6, D, W, D, WALL]];
const INNER_WALLS = [[0, 2.6, 2, 2.6], [2.8, 2.6, 5.05, 2.6], [5.85, 2.6, 6, 2.6], [6, 2.6, 6, D], [6, 4.36, 6.3, 4.36], [7.1, 4.36, W, 4.36], [7.4, 2.34, 7.4, 2.9], [7.4, 3.7, 7.4, 4.36],
[7.4, 2.34, W, 2.34], [3.1, 0, 3.1, 2.6], [3.1, 1.6, 3.6, 1.6], [4.4, 1.6, 4.9, 1.6], [4.9, 0, 4.9, 1.75], [4.9, 2.45, 4.9, 2.6], [4.9, 1.6, 5.15, 1.6], [5.75, 1.6, 6, 1.6], [6, 0, 6, 1.6]];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".floorplan").forEach((plan) => {
const css = (name) => getComputedStyle(plan).getPropertyValue(name).trim();
const stage = plan.querySelector(".floorplan__stage"), buttons = [...plan.querySelectorAll(".floorplan__rooms button")];
const renderer = new THREE.WebGLRenderer({ canvas: stage.querySelector("canvas"), antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.NeutralToneMapping; Object.assign(renderer.shadowMap, { enabled: true, type: THREE.PCFShadowMap });
const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100); scene.background = new THREE.Color(css("--bg"));
const sun = new THREE.DirectionalLight("#ffffff", 2.2); sun.position.set(-5, 12, 6); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -8, right: 8, top: 8, bottom: -8, near: 1, far: 40 }); Object.assign(sun.shadow, { bias: -0.0004, normalBias: 0.02 });
scene.add(sun, new THREE.HemisphereLight("#ffffff", css("--ground"), 1));
const box = new THREE.BoxGeometry(1, 1, 1), wallBox = new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), materials = [];
const add = (geometry, color, roughness, [x, y, z], [sx, sy, sz]) => {
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color, roughness })); materials.push(mesh.material);
Object.assign(mesh, { castShadow: true, receiveShadow: true }).position.set(x, y, z); mesh.scale.set(sx, sy, sz); scene.add(mesh); return mesh;
};
// 床は壁の厚みの半分だけ内側に収め、持ち上がっても壁に食い込まない
const slab = ([x0, z0, x1, z1], color) => add(box, color, 0.8, [(x0 + x1) / 2 - W / 2, SLAB / 2, (z0 + z1) / 2 - D / 2], [x1 - x0 - INNER, SLAB, z1 - z0 - INNER]);
const wall = ([x0, z0, x1, z1, h = WALL], thick) => { // 外壁は角で重なるよう、厚みの分だけ延ばす
const upright = x0 === x1, length = (upright ? z1 - z0 : x1 - x0) + (thick === OUTER ? thick : 0);
add(wallBox, css("--wall"), 0.75, [(x0 + x1) / 2 - W / 2, 0, (z0 + z1) / 2 - D / 2], [length, h, thick]).rotation.y = upright ? Math.PI / 2 : 0;
};
add(box, css("--base"), 0.9, [0, -0.15, 0], [W + 0.9, 0.3, D + 0.9]); // 図の下の台
FLOORS.forEach(([x0, z0, x1, z1, color]) => slab([x0, z0, x1, z1], css(color)));
OUTER_WALLS.forEach((w) => wall(w, OUTER)); INNER_WALLS.forEach((w) => wall(w, INNER));
// 持ち上がる部屋は一覧のボタンから作る。ラベルの文字も、ボタンの名前と広さをそのまま使う。v は持ち上がり(0〜1)
const [roomColor, liftedColor] = [new THREE.Color(css("--room")), new THREE.Color(css("--lifted"))];
const rooms = buttons.map((button) => {
const label = stage.appendChild(Object.assign(document.createElement("span"), { className: "floorplan__label" })), rect = button.dataset.rect.split(" ").map(Number);
label.append(...[...button.children].map((child) => child.cloneNode(true)));
return { button, rect, label, floor: slab(rect, roomColor.clone()), v: 0, tween: null };
});
// どの部屋に重なっているかは、ポインタから床の上面の高さの平面へ線を飛ばし、当たった点を部屋の四角と比べて決める。
// 持ち上がった床そのものに当てると、当たる高さが変わるたびに点が手前へずれて、部屋の境目で行き来する
const ray = new THREE.Raycaster(), plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -SLAB), hit = new THREE.Vector3(), ndc = new THREE.Vector2();
const roomAt = ({ clientX, clientY }) => {
const r = stage.getBoundingClientRect();
ray.setFromCamera(ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1), camera);
if (!ray.ray.intersectPlane(plane, hit)) return -1;
return rooms.findIndex(({ rect: [x0, z0, x1, z1] }) => hit.x + W / 2 >= x0 && hit.x + W / 2 <= x1 && hit.z + D / 2 >= z0 && hit.z + D / 2 <= z1);
};
// 持ち上げる部屋は、立体に重ねた部屋 → 一覧で重ねたかフォーカスした行 → 押して選んだ部屋 の順に決める
let pointer = null, hovered = -1, row = -1, pinned = -1, active = -1;
const update = () => {
const next = hovered >= 0 ? hovered : row >= 0 ? row : pinned;
if (next !== active) rooms.forEach((room, i) => { // 前のトゥイーンを止めて、いまの値から向きを変える
const up = i === next; room.tween?.kill(); active = next;
room.tween = gsap.to(room, { v: up ? 1 : 0, duration: reduceMotion ? 0 : up ? RISE : FALL, ease: up ? "power2.out" : "power2.inOut" });
});
};
const pin = (i) => { pinned = i === pinned ? -1 : i; buttons.forEach((b, k) => b.setAttribute("aria-pressed", String(k === pinned))); update(); };
stage.addEventListener("pointermove", (e) => { if (e.pointerType !== "touch") pointer = e; }); stage.addEventListener("pointerleave", () => { pointer = null; hovered = -1; update(); });
stage.addEventListener("click", (e) => pin(roomAt(e))); // タッチ端末では、タップで部屋を選ぶ
buttons.forEach((button, i) => {
const set = (value) => () => { row = value; update(); };
for (const [type, value] of [["pointerenter", i], ["pointerleave", -1], ["focus", i], ["blur", -1]]) button.addEventListener(type, set(value));
button.addEventListener("click", () => pin(i));
});
const anchor = new THREE.Vector3(); let now = 0, start = null;
const draw = (time) => {
now = time;
const azimuth = AZIMUTH + (reduceMotion ? 0 : SWAY * Math.sin((2 * Math.PI * time) / PERIOD)); // 図はゆっくり左右に向きを変える
camera.position.set(Math.sin(azimuth), 0, Math.cos(azimuth)).multiplyScalar(DISTANCE * Math.cos(ELEVATION)).y = DISTANCE * Math.sin(ELEVATION);
camera.lookAt(0, 0, 0.35); camera.updateMatrixWorld();
if (pointer) { hovered = roomAt(pointer); update(); } // 向きが変わると、止まったポインタの下の部屋も変わる
const sizes = rooms.map(({ label }) => [label.offsetWidth, label.offsetHeight]);
rooms.forEach(({ rect: [x0, z0, x1, z1], floor, label, button, v }, i) => {
floor.position.y = SLAB / 2 + v * LIFT; floor.material.color.lerpColors(roomColor, liftedColor, v); floor.material.emissive.setScalar(v * 0.06);
anchor.set((x0 + x1) / 2 - W / 2, SLAB + v * LIFT, (z0 + z1) / 2 - D / 2).project(camera);
const open = Math.min(1, v * 1.6), [w, h] = sizes[i]; // ラベルは部屋の中心の上。開くあいだ 8px 下から上がる
const x = Math.round(((anchor.x + 1) / 2) * stage.clientWidth - w / 2), y = Math.round(((1 - anchor.y) / 2) * stage.clientHeight - h - 14 + (1 - open) * 8);
label.style.transform = `translate(${x}px, ${y}px)`; label.style.setProperty("--open", open); button.style.setProperty("--lift", v);
});
renderer.render(scene, camera);
};
const resize = new ResizeObserver(() => { // 832:720 より縦長の表示領域では引いて、横に収める
const aspect = stage.clientWidth / stage.clientHeight; Object.assign(camera, { aspect, zoom: Math.min(1, aspect / (832 / 720)) }).updateProjectionMatrix();
renderer.setSize(stage.clientWidth, stage.clientHeight, false); draw(now); // 大きさは CSS が決めるので、style は書き換えない
});
const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting ? (ms) => draw((ms - (start ??= ms)) / 1000) : null));
resize.observe(stage); view.observe(plan);
plan.destroy = () => { // PJAX でセクションごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
renderer.setAnimationLoop(null); resize.disconnect(); view.disconnect(); rooms.forEach((room) => room.tween?.kill());
[box, wallBox, ...materials].forEach((item) => item.dispose()); renderer.dispose(); renderer.forceContextLoss();
};
});