bodev.vn

Tách tầng công trình

Tòa nhà isometric ráp lại rồi tách từng tầng theo cuộn. Mỗi tầng sáng lên kèm công năng, diện tích.

Cách hoạt động

  1. 1.Mỗi tầng là một nhóm SVG: sàn, tường thấp, cột, đồ bên trong. Nhãn tên tầng nằm trong nhóm nên đi theo tầng khi tách.
  2. 2.Tách tầng chỉ là dịch nhóm lên theo trục đứng màn hình, tầng k dịch k × GAP pixel.
  3. 3.Khi xem từng tầng, các tầng khác mờ đi, bảng bên phải đổi sang thông tin tầng đó.

Thử chỉnh

Đổi số tầng, công năng
Sửa mảng FLOORS: tên, tiêu đề, mô tả, số liệu, và hàm build() vẽ đồ bên trong bằng box(...).
Tách xa hơn
Tăng GAP trong file JS.
Đổi kích thước sàn
Sửa W, D (dài, rộng) và FLOOR_H (cao tầng).

Code tham khảo

<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tách tầng công trình · bodev.vn</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;700;800&display=swap" rel="stylesheet" />
<style>
:root {
  --bg: #eef0ec;
  --ink: #1f2a2e;
  --muted: #6b7773;
  --accent: #2a9d8f;
}

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Be Vietnam Pro", system-ui, sans-serif;
  background: var(--bg);
  color: var(--ink);
}

.intro,
.outro {
  min-height: 70vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 40px clamp(20px, 6vw, 80px);
}
.kicker { margin: 0; color: var(--accent); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.intro h1 { margin: 10px 0 0; font-size: clamp(40px, 7vw, 88px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }

.explode {
  height: 100vh;
  display: grid;
  grid-template-columns: 1.4fr 1fr;
  align-items: center;
  gap: 24px;
  padding: 24px clamp(20px, 5vw, 64px);
  overflow: hidden;
}
.building { width: 100%; height: 100%; max-height: 90vh; overflow: visible; }
.building .label { font-size: 15px; font-weight: 700; fill: var(--ink); }
.building .leader { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 3 3; }

.panel-kicker { margin: 0 0 12px; font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); }
.infos { position: relative; min-height: 220px; }
.info { position: absolute; inset: 0; opacity: 0; }
.info h2 { margin: 0; font-size: clamp(28px, 3.4vw, 44px); line-height: 1.05; }
.info p { max-width: 34ch; margin: 12px 0 0; line-height: 1.6; color: var(--muted); }
.info dl { display: flex; gap: 28px; margin: 18px 0 0; }
.info dt { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.info dd { margin: 4px 0 0; font-size: 22px; font-weight: 700; }

.outro { align-items: flex-start; }
.outro h2 { max-width: 20ch; margin: 0 0 24px; font-size: clamp(28px, 4vw, 48px); }
.btn {
  /* CTA trong demo là <button>, không gắn link. Nối vào form hoặc popup của bạn. */
  border: 0;
  font: inherit;
  cursor: pointer;
  padding: 14px 26px;
  border-radius: 999px;
  background: var(--ink);
  color: #fff;
  font-weight: 700;
}

@media (max-width: 760px) {
  .explode { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
  .building { max-height: 58vh; }
  .infos { min-height: 170px; }
  .info p { font-size: 14px; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Kiến trúc · Văn phòng 4 tầng</p>
  <h1>Bên trong có gì</h1>
  <p class="lead">Cuộn xuống để tách từng tầng.</p>
</section>

<section class="explode">
  <!-- JS dựng tòa nhà vào đây. Danh sách tầng nằm ở mảng FLOORS trong file JS. -->
  <svg class="building" viewBox="-150 -520 540 730" role="img" aria-label="Tòa nhà tách tầng"></svg>

  <div class="panel" aria-live="polite">
    <p class="panel-kicker">Mặt bằng công năng</p>
    <div class="infos"></div>
  </div>
</section>

<section class="outro">
  <h2>Cần thể hiện công năng dự án rõ như vậy?</h2>
  <button type="button" class="btn">Trao đổi với kiến trúc sư</button>
</section>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script>
// Tách tầng công trình — bodev.vn/lab/tach-tang-cong-trinh
// Cần: gsap + ScrollTrigger.

gsap.registerPlugin(ScrollTrigger);

// ---------- 1. Vẽ isometric (giống công thức "Lắp ráp căn phòng") ----------
const UNIT = 22;
const COS = Math.cos(Math.PI / 6);
const SIN = Math.sin(Math.PI / 6);
const iso = (x, y, z = 0) => [(x - y) * COS * UNIT, (x + y) * SIN * UNIT - z * UNIT];
const pts = (...list) => list.map((p) => iso(...p).map((n) => n.toFixed(1)).join(",")).join(" ");

const NS = "http://www.w3.org/2000/svg";
const svg = document.querySelector(".building");
function el(tag, attrs, parent = svg) {
  const node = document.createElementNS(NS, tag);
  for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
  parent.appendChild(node);
  return node;
}
function shade(hex, amount) {
  const n = parseInt(hex.slice(1), 16);
  const f = (c) => Math.round(c * (1 - amount));
  return `rgb(${f(n >> 16)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`;
}
function box(g, x, y, z, w, d, h, color) {
  el("polygon", { points: pts([x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]), fill: shade(color, 0.12) }, g);
  el("polygon", { points: pts([x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]), fill: shade(color, 0.25) }, g);
  el("polygon", { points: pts([x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]), fill: color }, g);
}
function tree(g, x, y, z, r) {
  box(g, x - 0.05, y - 0.05, z, 0.1, 0.1, r * 1.2, "#6b4a33");
  const [cx, cy] = iso(x, y, z + r * 1.6);
  el("circle", { cx, cy, r: r * UNIT * 0.9, fill: "#5d8a5a" }, g);
}

// ---------- 2. Các tầng: sửa mảng này cho dự án của bạn ----------
const W = 10; // chiều dài (trục x)
const D = 6; // chiều rộng (trục y)
const FLOOR_H = 2.4; // chiều cao tầng
const SLAB = 0.3;

const FLOORS = [
  {
    name: "Tầng 1",
    title: "Sảnh & café",
    desc: "Sảnh đón mở ra phố, quầy café bên trái, bàn ngồi sát kính.",
    stats: [["Diện tích", "520 m²"], ["Công năng", "Công cộng"]],
    build(g, z) {
      box(g, 0.8, 0.8, z, 3.2, 0.9, 1, "#a47551");
      for (const [x, y] of [[5.5, 3.2], [7.8, 3.2], [5.5, 4.8], [7.8, 4.8]]) box(g, x, y, z, 0.9, 0.9, 0.7, "#e9e4da");
      tree(g, 2, 4.6, z, 0.7);
    },
  },
  {
    name: "Tầng 2",
    title: "Văn phòng mở",
    desc: "40 chỗ ngồi, bàn xếp theo cụm 4 người, lối đi ven kính.",
    stats: [["Diện tích", "540 m²"], ["Chỗ ngồi", "40"]],
    build(g, z) {
      for (let i = 0; i < 4; i++) for (let j = 0; j < 2; j++) box(g, 1.2 + i * 2.1, 1.2 + j * 2.3, z, 1.6, 1.2, 0.55, "#f4f1ea");
    },
  },
  {
    name: "Tầng 3",
    title: "Phòng họp & thư viện",
    desc: "2 phòng họp vách kính, kệ sách chạy dọc tường, góc đọc yên tĩnh.",
    stats: [["Diện tích", "540 m²"], ["Phòng họp", "2"]],
    build(g, z) {
      box(g, 0.3, 0.8, z, 0.6, 4.4, 1.9, "#8a6244");
      box(g, 5, 0.8, z, 3.6, 1.4, 0.55, "#f4f1ea");
      box(g, 5, 3.4, z, 3.6, 1.4, 0.55, "#f4f1ea");
      el("polygon", { points: pts([4.5, 2.8, z], [9.3, 2.8, z], [9.3, 2.8, z + 1.8], [4.5, 2.8, z + 1.8]), fill: "rgba(160,200,215,0.35)", stroke: "#8fb3c2" }, g);
    },
  },
  {
    name: "Mái",
    title: "Sân vườn trên mái",
    desc: "Vườn rau, cây bóng mát và giàn năng lượng mặt trời.",
    stats: [["Diện tích", "480 m²"], ["Cây xanh", "35%"]],
    roof: true,
    build(g, z) {
      box(g, 0.6, 0.6, z, 3.4, 2.2, 0.25, "#7aa26a");
      box(g, 0.6, 3.2, z, 3.4, 2.2, 0.25, "#7aa26a");
      tree(g, 6, 2, z, 0.9);
      tree(g, 8.4, 4.3, z, 0.7);
      el("polygon", { points: pts([5.2, 3.6, z + 1.2], [7.6, 3.6, z + 1.2], [7.6, 5.4, z + 0.6], [5.2, 5.4, z + 0.6]), fill: "#2c4a63" }, g);
    },
  },
];

// ---------- 3. Dựng tòa nhà ----------
const floorGroups = FLOORS.map((floor, k) => {
  const z = k * FLOOR_H;
  const g = el("g", { class: "floor" });
  box(g, 0, 0, z, W, D, SLAB, floor.roof ? "#c9c4b8" : "#dcd7cc");
  // tường thấp hai cạnh sau, để nhìn thấy bên trong
  box(g, 0, 0, z + SLAB, W, 0.15, 0.8, "#e9e5dc");
  box(g, 0, 0, z + SLAB, 0.15, D, 0.8, "#e9e5dc");
  floor.build(g, z + SLAB);
  if (!floor.roof) {
    for (const [x, y] of [[0, 0], [W - 0.3, 0], [0, D - 0.3], [W - 0.3, D - 0.3]]) box(g, x, y, z + SLAB, 0.3, 0.3, FLOOR_H - SLAB, "#cfc9bd");
  }
  // nhãn + đường dẫn, nằm trong group nên đi theo tầng khi tách
  const [ax, ay] = iso(W, 0, z + SLAB);
  const label = el("g", { class: "tag", opacity: 0 }, g);
  el("line", { class: "leader", x1: ax, y1: ay, x2: ax + 60, y2: ay }, label);
  const text = el("text", { class: "label", x: ax + 66, y: ay + 5 }, label);
  text.textContent = floor.name;
  return g;
});

// Bảng thông tin bên phải
const infos = document.querySelector(".infos");
const overview = { title: "4 tầng · 2.080 m² sàn", desc: "Văn phòng nhỏ cho 60 người, tầng trệt mở cho khách.", stats: [["Hoàn thành", "2026"], ["Vị trí", "Đà Nẵng"]] };
const cards = [overview, ...FLOORS].map((f) => {
  const card = document.createElement("div");
  card.className = "info";
  const h = document.createElement("h2");
  h.textContent = f.name ? `${f.name}: ${f.title}` : f.title;
  const p = document.createElement("p");
  p.textContent = f.desc;
  const dl = document.createElement("dl");
  for (const [k, v] of f.stats) {
    const wrap = document.createElement("div");
    const dt = document.createElement("dt");
    dt.textContent = k;
    const dd = document.createElement("dd");
    dd.textContent = v;
    wrap.append(dt, dd);
    dl.appendChild(wrap);
  }
  card.append(h, p, dl);
  infos.appendChild(card);
  return card;
});
gsap.set(cards[0], { opacity: 1 });

// ---------- 4. Cuộn: ráp → tách → xem từng tầng ----------
const GAP = 95; // px tách giữa hai tầng
const tl = gsap.timeline({ defaults: { ease: "power3.inOut", duration: 1 } });

tl.from(floorGroups, { y: -160, opacity: 0, stagger: 0.25, ease: "power3.out" }, 0);
floorGroups.forEach((g, k) => tl.to(g, { y: -k * GAP }, 1.6));
tl.to(".tag", { opacity: 1, duration: 0.4, stagger: 0.1 }, 2.4);

FLOORS.forEach((_, i) => {
  const at = 3.2 + i;
  tl.to(floorGroups, { opacity: 0.25, duration: 0.4 }, at)
    .to(floorGroups[i], { opacity: 1, duration: 0.4 }, at)
    .to(cards, { opacity: 0, y: -12, duration: 0.3 }, at)
    .fromTo(cards[i + 1], { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.3 }, at + 0.2);
});
tl.to(floorGroups, { opacity: 1, duration: 0.4 }, 3.2 + FLOORS.length);

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  tl.progress(1);
} else {
  ScrollTrigger.create({
    trigger: ".explode",
    start: "top top",
    end: "+=3200",
    pin: true,
    scrub: 1,
    animation: tl,
  });
}

gsap.from(".intro > *", { opacity: 0, y: 30, duration: 0.8, stagger: 0.1, ease: "expo.out" });
</script>
</body>
</html>

Code để học cách làm, không phải thư viện. Không đảm bảo chạy ngay trên site khác, dùng thì tự kiểm tra.

Tiếp trong Kiến trúc & kỹ sưHồ sơ dự án xếp chồng