bodev.vn

Mặt cắt thông gió nhà ống

Mặt cắt nhà phố tự vẽ, nắng rọi qua giếng trời, gió chạy xuyên các tầng rồi thoát lên mái.

Cách hoạt động

  1. 1.Mũi tên gió chạy theo đường cong bằng MotionPathPlugin (autoRotate để mũi tên quay theo hướng gió). Mỗi đường có 4 mũi tên rải đều.
  2. 2.Gió là vòng lặp chạy tự do, không gắn vào thanh cuộn: cuộn tới bước 3 thì bật, cuộn ngược lên hoặc rời section thì tắt.
  3. 3.Các bước còn lại (vẽ mặt cắt, nắng, khí nóng, nhiệt độ) nằm trên một timeline scrub như bình thường.
  4. 4.Đường gió ẩn trong SVG chỉ làm quỹ đạo. Vẽ lại đường là gió đổi hướng, không phải sửa JS.

Thử chỉnh

Gió nhanh, chậm hơn
Sửa WIND_SECONDS (số giây chạy hết một đường).
Nhiều mũi tên hơn
Tăng ARROWS_PER_PATH.
Thêm luồng gió
Thêm một path trong .winds. JS tự rải mũi tên lên đó.

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>Mặt cắt thông gió nhà ống · 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 {
  --paper: #f3f1eb;
  --ink: #22333b;
  --muted: #6c7a7f;
  --wind: #2a9d8f;
  --sun: #f4a261;
  --hot: #e76f51;
}

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Be Vietnam Pro", system-ui, sans-serif;
  background: var(--paper);
  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(--wind); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.intro h1 { max-width: 14ch; margin: 10px 0 0; font-size: clamp(38px, 6.5vw, 80px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }

.section-view {
  height: 100vh;
  display: grid;
  grid-template-columns: minmax(180px, 260px) 1fr;
  align-items: center;
  gap: 24px;
  padding: 24px clamp(20px, 5vw, 64px);
  overflow: hidden;
}
.steps { margin: 0; padding: 0; list-style: none; display: grid; gap: 14px; }
.step { font-size: 17px; font-weight: 500; opacity: 0.3; }
.step b { display: inline-block; width: 2.4em; color: var(--wind); }

.cut { width: 100%; max-height: 88vh; overflow: visible; }
.drawing .ln { fill: none; stroke: var(--ink); stroke-width: 2; stroke-linecap: round; }
.drawing .bold { stroke-width: 4; }
.drawing .thin { stroke-width: 1.2; }

.sun { fill: var(--sun); }
.rays line { stroke: var(--sun); stroke-width: 3; stroke-dasharray: 6 8; opacity: 0.8; }
.light ellipse { fill: #ffd27a; opacity: 0.75; }
.heat { fill: url(#warm); }

.winds path { fill: none; stroke: var(--wind); stroke-width: 1.5; stroke-dasharray: 3 7; opacity: 0.35; }
.arrow { fill: var(--wind); }

.temp rect { fill: #fff; stroke: rgba(34, 51, 59, 0.15); }
.temp text { fill: var(--muted); font-size: 13px; }
.temp .deg { fill: var(--ink); font-size: 20px; font-weight: 800; }
.temp.out .deg { fill: var(--hot); }
.temp.in .deg { fill: var(--wind); }

.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) {
  .section-view { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
  .steps { grid-auto-flow: column; overflow: hidden; gap: 10px; }
  .step { font-size: 13px; white-space: nowrap; }
  .step b { width: auto; margin-right: 4px; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Kiến trúc · Nhà ống 5×20m</p>
  <h1>Nhà mát mà không cần máy lạnh</h1>
  <p class="lead">Cuộn xuống để xem nắng và gió đi qua ngôi nhà.</p>
</section>

<section class="section-view">
  <ol class="steps">
    <li class="step"><b>01</b> Mặt cắt dọc nhà</li>
    <li class="step"><b>02</b> Giếng trời lấy sáng</li>
    <li class="step"><b>03</b> Gió xuyên phòng</li>
    <li class="step"><b>04</b> Khí nóng thoát mái</li>
    <li class="step"><b>05</b> Trong mát hơn ngoài</li>
  </ol>

  <svg class="cut" viewBox="0 0 1000 640" role="img" aria-label="Mặt cắt nhà ống với giếng trời và luồng gió">
    <defs>
      <linearGradient id="warm" x1="0" y1="1" x2="0" y2="0">
        <stop offset="0" stop-color="#f4a261" stop-opacity="0" />
        <stop offset="1" stop-color="#e76f51" stop-opacity="0.45" />
      </linearGradient>
    </defs>

    <!-- Mặt trời và tia nắng: tia phải lọt qua ô kính giếng trời (x 450–560 ở cao độ mái), không xuyên mái bê tông -->
    <circle class="sun" cx="566" cy="30" r="22" />
    <g class="rays">
      <line x1="548" y1="58" x2="470" y2="540" />
      <line x1="556" y1="60" x2="500" y2="540" />
      <line x1="562" y1="58" x2="528" y2="540" />
    </g>
    <!-- Vệt sáng rơi xuống các sàn -->
    <g class="light">
      <ellipse cx="505" cy="556" rx="70" ry="7" />
      <ellipse cx="428" cy="436" rx="36" ry="5" />
      <ellipse cx="584" cy="316" rx="36" ry="5" />
    </g>
    <!-- Khí nóng bốc lên trong giếng trời -->
    <rect class="heat" x="452" y="110" width="106" height="450" />

    <!-- Bản vẽ: nét nào có class="ln" sẽ tự vẽ ra -->
    <g class="drawing">
      <line class="ln bold" x1="30" y1="560" x2="970" y2="560" />
      <path class="ln bold" d="M120 560 V110 H450 M560 110 H880 V560" />
      <path class="ln" d="M120 440 H450 M560 440 H880 M120 320 H450 M560 320 H880 M120 200 H450 M560 200 H880" />
      <!-- mái kính giếng trời, nâng cao để gió thoát -->
      <path class="ln" d="M440 110 V84 H570 V110" />
      <path class="ln thin" d="M452 96 H558" />
      <!-- cửa mặt tiền và cửa sau -->
      <path class="ln thin" d="M120 480 V540 M120 360 V420 M120 240 V300 M880 480 V540 M880 360 V420" />
      <!-- cầu thang -->
      <path class="ln thin" d="M720 560 L800 440 M720 440 L800 320 M720 320 L800 200" />
      <!-- cây trong giếng trời và cây trước nhà -->
      <path class="ln" d="M505 560 V510" />
      <circle class="ln" cx="505" cy="495" r="18" />
      <path class="ln" d="M70 560 V480" />
      <circle class="ln" cx="70" cy="455" r="30" />
    </g>

    <!-- Đường gió: mũi tên chạy theo các đường này (ẩn, chỉ làm quỹ đạo) -->
    <g class="winds">
      <path id="wind-1" d="M20 510 C 200 505, 380 520, 505 520 C 520 400, 500 250, 505 100 C 510 60, 560 40, 640 30" />
      <path id="wind-2" d="M20 395 C 220 390, 380 395, 490 380 C 500 280, 500 180, 505 100 C 515 60, 580 45, 660 40" />
      <path id="wind-3" d="M20 275 C 220 270, 400 270, 495 260 C 505 200, 505 150, 505 100 C 520 70, 590 60, 680 55" />
    </g>
    <g class="arrows"></g>

    <g class="temps">
      <g class="temp out">
        <rect x="24" y="16" width="170" height="54" rx="12" />
        <text x="40" y="40">Ngoài trời</text>
        <text class="deg" x="40" y="62"><tspan data-temp="36">36</tspan>°C</text>
      </g>
      <g class="temp in">
        <rect x="200" y="470" width="170" height="54" rx="12" />
        <text x="216" y="494">Trong nhà</text>
        <text class="deg" x="216" y="516"><tspan data-temp="29">29</tspan>°C</text>
      </g>
    </g>
  </svg>
</section>

<section class="outro">
  <h2>Thiết kế theo khí hậu, không theo máy lạnh.</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 src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js"></script>
<script>
// Mặt cắt thông gió nhà ống — bodev.vn/lab/mat-cat-thong-gio
// Cần: gsap + ScrollTrigger + MotionPathPlugin.

gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);

const NS = "http://www.w3.org/2000/svg";
const steps = document.querySelectorAll(".step");
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;

// ---------- 1. Mũi tên gió chạy vòng theo MotionPath ----------
// Mũi tên là vòng lặp chạy tự do (không gắn vào thanh cuộn), vì gió phải chạy liên tục khi đang xem.
const ARROWS_PER_PATH = 4;
const WIND_SECONDS = 4.5;
const arrowsBox = document.querySelector(".arrows");
const windLoops = [];
document.querySelectorAll(".winds path").forEach((path, p) => {
  for (let i = 0; i < ARROWS_PER_PATH; i++) {
    const arrow = document.createElementNS(NS, "path");
    arrow.setAttribute("class", "arrow");
    arrow.setAttribute("d", "M-7 -5 L7 0 L-7 5 Z");
    arrowsBox.appendChild(arrow);
    const loop = gsap.to(arrow, {
      motionPath: { path, align: path, alignOrigin: [0.5, 0.5], autoRotate: true },
      duration: WIND_SECONDS,
      ease: "none",
      repeat: -1,
      paused: true,
    });
    // Rải đều các mũi tên trên đường, đường sau lệch nhịp với đường trước.
    loop.progress((i / ARROWS_PER_PATH + p * 0.13) % 1);
    windLoops.push(loop);
  }
});
gsap.set(".arrows", { opacity: 0 });

let windOn = false;
function setWind(on) {
  if (on === windOn) return;
  windOn = on;
  windLoops.forEach((l) => (on ? l.play() : l.pause()));
  gsap.to(".arrows", { opacity: on ? 1 : 0, duration: 0.4 });
}

// ---------- 2. Bản vẽ tự vẽ: stroke-dashoffset từ độ dài nét về 0 ----------
const lines = [...document.querySelectorAll(".drawing .ln")];
lines.forEach((el) => {
  const len = el.getTotalLength();
  el.style.strokeDasharray = `${len}`;
  el.style.strokeDashoffset = `${len}`;
});

function highlight(tl, n, at) {
  tl.to(steps, { opacity: 0.3, x: 0, duration: 0.2 }, at).to(steps[n], { opacity: 1, x: 8, duration: 0.2 }, at);
}

// ---------- 3. Timeline theo cuộn ----------
const WIND_AT = 2.6; // thời điểm trên timeline gió bắt đầu thổi
const tl = gsap.timeline({ defaults: { ease: "power2.out" } });

highlight(tl, 0, 0);
tl.to(lines, { strokeDashoffset: 0, duration: 1, stagger: 0.05, ease: "power1.inOut" }, 0);

highlight(tl, 1, 1.3);
tl.from(".sun", { y: -80, opacity: 0, duration: 0.6 }, 1.3)
  .from(".rays line", { scaleY: 0, transformOrigin: "50% 0%", opacity: 0, duration: 0.7, stagger: 0.1 }, 1.5)
  .from(".light ellipse", { scale: 0, transformOrigin: "50% 50%", opacity: 0, duration: 0.5, stagger: 0.1 }, 1.9);

highlight(tl, 2, WIND_AT);
tl.from(".winds path", { opacity: 0, duration: 0.5, stagger: 0.1 }, WIND_AT);

highlight(tl, 3, 3.6);
tl.from(".heat", { scaleY: 0, transformOrigin: "50% 100%", opacity: 0, duration: 0.8 }, 3.6);

highlight(tl, 4, 4.6);
tl.from(".temp", { opacity: 0, y: 12, duration: 0.4, stagger: 0.2 }, 4.6);
// Nhiệt độ đếm từ 20 lên con số thật.
document.querySelectorAll("[data-temp]").forEach((node) => {
  const target = Number(node.dataset.temp);
  const counter = { v: 20 };
  tl.to(counter, { v: target, duration: 0.8, onUpdate: () => (node.textContent = Math.round(counter.v)) }, 4.7);
});

if (reduceMotion) {
  tl.progress(1);
  // Không chạy vòng lặp: để mũi tên đứng yên giữa đường cho thấy hướng gió.
  windLoops.forEach((l) => l.progress(0.5));
  gsap.set(".arrows", { opacity: 1 });
} else {
  ScrollTrigger.create({
    trigger: ".section-view",
    start: "top top",
    end: "+=2800",
    pin: true,
    scrub: 1,
    animation: tl,
    // Gió thổi khi đã tới bước 3, tắt khi cuộn ngược lên hoặc rời khỏi section.
    onUpdate: (self) => setWind(self.isActive && self.progress * tl.duration() >= WIND_AT),
    onToggle: (self) => !self.isActive && setWind(false),
  });
}

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ưBản vẽ thành công trình