bodev.vn

Chọn tầng, xem căn trống

Rê chuột lên tòa nhà để chọn tầng. Bảng bên cạnh hiện căn còn, đã cọc, đã bán.

Cách hoạt động

  1. 1.Công cụ bán hàng: khách tự xem tầng nào còn căn, không cần gọi hỏi.
  2. 2.Dữ liệu căn hộ nằm trong một mảng ở đầu file JS. Nối API hay xuất từ Excel sang đều được.
  3. 3.Trên điện thoại thì chạm vào tầng. Bảng thông tin tự xuống dưới tòa nhà.

Thử chỉnh

Đổi số tầng, số căn mỗi tầng
Sửa FLOORS và UNITS_PER_FLOOR ở đầu file JS.
Dùng dữ liệu thật
Thay hàm makeUnits bằng dữ liệu của bạn, giữ đúng dạng { code, area, status }.
Đổi màu trạng thái
Sửa --free, --deposit, --sold trong CSS.

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>Chọn tầng, xem căn trố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 {
  --bg: #f5f1ea;
  --text: #1b1b18;
  --muted: #6f6a60;
  --building: #d9d1c3;
  --floor: #ece6da;
  --accent: #b5452b;
  --free: #2f8f5b;
  --deposit: #d49a1f;
  --sold: #9a958b;
}

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

.picker {
  min-height: 100vh;
  display: grid;
  grid-template-columns: minmax(220px, 340px) 1fr;
  gap: clamp(24px, 5vw, 64px);
  align-items: center;
  padding: 40px clamp(20px, 5vw, 64px);
}

.building-wrap { position: relative; }
.hint { margin: 0 0 12px; font-size: 13px; color: var(--muted); }
.building { width: 100%; max-height: 82vh; display: block; }
.building .roof { fill: var(--text); }
.building .ground { fill: var(--building); }
.building .floor {
  fill: var(--floor);
  stroke: var(--building);
  stroke-width: 2;
  cursor: pointer;
  outline: none;
}
.building .floor:focus-visible { stroke: var(--accent); stroke-width: 3; }
.building .floor.active { fill: #fff; }
.building .highlight {
  fill: none;
  stroke: var(--accent);
  stroke-width: 3;
  pointer-events: none;
}

.tooltip {
  position: absolute;
  /* Nằm giữa tòa nhà, ngay trên tầng đang rê. */
  left: 50%;
  top: 0;
  translate: -50% 0;
  padding: 8px 12px;
  border-radius: 8px;
  background: var(--text);
  color: #fff;
  font-size: 13px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
}

.panel-kicker { margin: 0; font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); }
.panel-title { margin: 6px 0 20px; font-size: clamp(40px, 6vw, 72px); font-weight: 800; line-height: 1; }
.floor-no { display: inline-block; min-width: 2ch; }

.legend { display: flex; flex-wrap: wrap; gap: 20px; margin: 0 0 24px; padding: 0; list-style: none; font-size: 15px; }
.legend b { margin-left: 4px; }
.dot { display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; }
.dot.free { background: var(--free); }
.dot.deposit { background: var(--deposit); }
.dot.sold { background: var(--sold); }

.units { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.unit {
  padding: 14px 12px;
  border-radius: 12px;
  border: 1.5px solid transparent;
  background: #fff;
  font: inherit;
  text-align: left;
  cursor: pointer;
  /* Hover dùng translate, không dùng transform: tránh đá nhau với GSAP. */
  transition: border-color 0.2s, translate 0.2s;
}
.unit:hover { translate: 0 -2px; border-color: var(--text); }
.unit b { display: block; font-size: 17px; }
.unit span { font-size: 13px; color: var(--muted); }
.unit[data-status="free"] { box-shadow: inset 4px 0 0 var(--free); }
.unit[data-status="deposit"] { box-shadow: inset 4px 0 0 var(--deposit); }
.unit[data-status="sold"] { box-shadow: inset 4px 0 0 var(--sold); opacity: 0.55; cursor: not-allowed; }

.panel-foot { margin: 20px 0 0; font-size: 13px; color: var(--muted); }

@media (max-width: 760px) {
  .picker { grid-template-columns: 1fr; gap: 20px; padding: 20px; align-items: start; }
  .building { max-height: 280px; margin: 0 auto; }
  .tooltip { display: none; }
  .units { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<section class="picker">
  <div class="building-wrap">
    <p class="hint">Rê chuột hoặc chạm vào một tầng</p>
    <svg class="building" viewBox="0 0 260 600" aria-label="Chọn tầng">
      <rect class="roof" x="30" y="10" width="200" height="22" rx="3" />
      <g class="floors"></g>
      <rect class="highlight" x="22" y="0" width="216" height="0" rx="4" />
      <rect class="ground" x="0" y="578" width="260" height="22" />
    </svg>
    <div class="tooltip" role="status" aria-live="polite"></div>
  </div>

  <aside class="panel" aria-live="polite">
    <p class="panel-kicker">Tòa A · Mặt bằng</p>
    <h2 class="panel-title">Tầng <span class="floor-no">—</span></h2>
    <ul class="legend">
      <li><span class="dot free"></span>Còn trống <b data-sum="free">0</b></li>
      <li><span class="dot deposit"></span>Đã cọc <b data-sum="deposit">0</b></li>
      <li><span class="dot sold"></span>Đã bán <b data-sum="sold">0</b></li>
    </ul>
    <div class="units"></div>
    <p class="panel-foot">Giá và pháp lý cập nhật theo ngày. Liên hệ để giữ căn.</p>
  </aside>
</section>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
// Chọn tầng, xem căn trống — bodev.vn/lab/chon-tang
// Cần: gsap (core). Không cần plugin.

const FLOORS = 20;
const UNITS_PER_FLOOR = 8;
const STATUS_LABEL = { free: "Còn trống", deposit: "Đã cọc", sold: "Đã bán" };

// Dữ liệu mẫu. Thay bằng dữ liệu thật: mỗi căn là { code, area, status }.
function makeUnits(floor) {
  return Array.from({ length: UNITS_PER_FLOOR }, (_, i) => {
    const seed = (floor * 31 + i * 17) % 10;
    const status = seed < 4 ? "sold" : seed < 6 ? "deposit" : "free";
    return {
      code: `${floor}.${String(i + 1).padStart(2, "0")}`,
      area: 52 + ((floor + i * 7) % 5) * 12,
      status,
    };
  });
}

const svg = document.querySelector(".building");
const floorsGroup = svg.querySelector(".floors");
const highlight = svg.querySelector(".highlight");
const tooltip = document.querySelector(".tooltip");
const floorNo = document.querySelector(".floor-no");
const unitsBox = document.querySelector(".units");

// Toạ độ trong SVG: tầng 1 nằm dưới cùng.
const TOP = 36;
const BOTTOM = 576;
const FLOOR_H = (BOTTOM - TOP) / FLOORS;
const floorY = (n) => BOTTOM - n * FLOOR_H;

const data = {};
for (let n = 1; n <= FLOORS; n++) {
  data[n] = makeUnits(n);
  const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
  rect.setAttribute("class", "floor");
  rect.setAttribute("x", 30);
  rect.setAttribute("y", floorY(n) + 1);
  rect.setAttribute("width", 200);
  rect.setAttribute("height", FLOOR_H - 2);
  rect.setAttribute("tabindex", "0");
  rect.setAttribute("role", "button");
  rect.setAttribute("aria-label", `Tầng ${n}`);
  rect.dataset.floor = n;
  floorsGroup.appendChild(rect);
}

const countBy = (units, status) => units.filter((u) => u.status === status).length;

// Khung viền đỏ trượt theo chuột. quickTo tạo hàm tween sẵn, gọi liên tục không tốn.
// Dùng y (transform) chứ không dùng attr.y: quickTo chỉ nhận thuộc tính đơn.
gsap.set(highlight, { attr: { y: 0, height: FLOOR_H }, y: floorY(1) });
const moveHighlight = gsap.quickTo(highlight, "y", { duration: 0.35, ease: "power3" });

function preview(n) {
  moveHighlight(floorY(n));
  tooltip.textContent = `Tầng ${n} · ${countBy(data[n], "free")} căn trống`;
  // Đo vị trí tầng thật trên màn hình, đúng ở mọi kích thước khung.
  const floor = floorsGroup.querySelector(`[data-floor="${n}"]`).getBoundingClientRect();
  const wrap = svg.parentElement.getBoundingClientRect();
  gsap.to(tooltip, { y: floor.top - wrap.top - 40, opacity: 1, duration: 0.25 });
}

let selected = null;

function select(n) {
  if (n === selected) return;
  selected = n;
  moveHighlight(floorY(n));
  const units = data[n];
  floorsGroup.querySelectorAll(".floor").forEach((f) => f.classList.toggle("active", +f.dataset.floor === n));

  // Số tầng lật lên.
  gsap.fromTo(floorNo, { yPercent: 60, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.4, ease: "back.out(2)" });
  floorNo.textContent = n;
  for (const status of Object.keys(STATUS_LABEL)) {
    document.querySelector(`[data-sum="${status}"]`).textContent = countBy(units, status);
  }

  unitsBox.innerHTML = "";
  units.forEach((u) => {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "unit";
    btn.dataset.status = u.status;
    btn.disabled = u.status === "sold";
    // textContent thay vì innerHTML: dữ liệu từ API không chèn được mã độc vào trang.
    const code = document.createElement("b");
    code.textContent = u.code;
    const info = document.createElement("span");
    info.textContent = `${u.area} m² · ${STATUS_LABEL[u.status]}`;
    btn.append(code, info);
    unitsBox.appendChild(btn);
  });
  gsap.from(unitsBox.children, { opacity: 0, y: 16, scale: 0.9, duration: 0.45, stagger: 0.035, ease: "power3.out" });
}

floorsGroup.addEventListener("pointerover", (e) => {
  const n = +e.target.dataset.floor;
  if (n) preview(n);
});
floorsGroup.addEventListener("click", (e) => {
  const n = +e.target.dataset.floor;
  if (n) select(n);
});
floorsGroup.addEventListener("keydown", (e) => {
  const n = +e.target.dataset.floor;
  if (n && (e.key === "Enter" || e.key === " ")) {
    e.preventDefault();
    select(n);
  }
});
floorsGroup.addEventListener("focusin", (e) => {
  const n = +e.target.dataset.floor;
  if (n) preview(n);
});
svg.addEventListener("pointerleave", () => {
  gsap.to(tooltip, { opacity: 0, duration: 0.2 });
  if (selected) moveHighlight(floorY(selected));
});

// Mở trang: các tầng xếp chồng từ dưới lên, rồi chọn sẵn tầng giữa.
const intro = gsap.timeline();
intro
  .from(".floor", { scaleX: 0, transformOrigin: "50% 50%", duration: 0.5, stagger: 0.03, ease: "power3.out" })
  .from(".roof", { y: -30, opacity: 0, duration: 0.5, ease: "back.out(2)" }, "-=0.2")
  .add(() => select(Math.ceil(FLOORS / 2)));

if (matchMedia("(prefers-reduced-motion: reduce)").matches) intro.progress(1);
</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 Bất động sảnTiến độ dự án cuộn ngang