bodev.vn

Bản đồ tiện ích quanh dự án

Vòng bán kính 1/3/5 km lan ra, ghim trường, bệnh viện, mua sắm rơi xuống theo nhóm, xe chạy về trung tâm.

Cách hoạt động

  1. 1.Tiện ích nằm trong một mảng dữ liệu (nhóm, tên, toạ độ, số phút). JS dựng ghim, vòng bán kính và bảng bên phải từ mảng đó.
  2. 2.Mỗi ghim có hai lớp: lớp ngoài giữ vị trí bằng transform tĩnh, lớp trong để GSAP làm rơi xuống. Hai transform không đá nhau.
  3. 3.Vòng bán kính phóng ra bằng svgOrigin (toạ độ viewBox), nên vòng nào cũng nở ra từ đúng vị trí dự án.
  4. 4.Tuyến về trung tâm: nét đường vẽ bằng stroke-dashoffset, xe chạy bằng MotionPathPlugin, hai tween cùng nhịp nên xe luôn ở đầu nét vẽ.

Thử chỉnh

Đổi tiện ích
Sửa mảng PLACES: tên, x, y trên bản đồ, số phút đi.
Đổi bán kính
Sửa mảng RINGS: km và r (bán kính theo đơn vị viewBox).
Đổi tuyến đường
Sửa thuộc tính d của path #route. Xe tự chạy theo.

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>Bản đồ tiện ích quanh dự án · 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: #f4f1ea;
  --ink: #1d2320;
  --muted: #6d746f;
  --accent: #c8553d;
  --school: #3d7ea6;
  --hospital: #d64550;
  --mall: #e0a030;
  --park: #4f8a5b;
}

* { 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 { max-width: 13ch; 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); }

.map-section {
  height: 100vh;
  display: grid;
  grid-template-columns: 1.6fr 1fr;
  align-items: center;
  gap: clamp(16px, 3vw, 40px);
  padding: 24px clamp(20px, 5vw, 64px);
  overflow: hidden;
}
.map-wrap { border-radius: 22px; overflow: hidden; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.35); }
.map { display: block; width: 100%; height: auto; }

.map .land { fill: #eceee6; }
.map .park { fill: #cfe0c3; }
.map .river { fill: none; stroke: #a9cfdc; stroke-width: 42; stroke-linecap: round; }
.map .road { fill: none; stroke: #fff; stroke-width: 10; stroke-linecap: round; }
.map .road.major { stroke-width: 16; }
.map .road.minor { stroke-width: 5; stroke: #fbfbf8; }
.map .district { fill: #9aa39c; font-size: 16px; font-weight: 700; letter-spacing: 0.2em; }

.map .ring { fill: rgba(200, 85, 61, 0.05); stroke: var(--accent); stroke-width: 2; stroke-dasharray: 8 8; }
.map .ring-label { fill: var(--accent); font-size: 14px; font-weight: 700; }
.map .project .dot { fill: var(--accent); stroke: #fff; stroke-width: 4; }
.map .project .pulse { fill: var(--accent); opacity: 0.35; }

.map .route { fill: none; stroke: var(--ink); stroke-width: 5; stroke-linecap: round; }
.map .car { fill: var(--ink); stroke: #fff; stroke-width: 3; }
.map .trip rect { fill: var(--ink); }
.map .trip text { fill: #fff; font-size: 16px; font-weight: 700; }

.map .pin-head { stroke: #fff; stroke-width: 2.5; }
.map .pin-label { font-size: 13px; font-weight: 600; fill: var(--ink); paint-order: stroke; stroke: #fff; stroke-width: 4px; }

.panel-kicker { margin: 0; color: var(--accent); font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; }
.panel h2 { margin: 6px 0 18px; font-size: clamp(28px, 3.4vw, 44px); line-height: 1.05; }
.groups { margin: 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.group {
  display: grid;
  grid-template-columns: 14px 1fr auto;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  border-radius: 14px;
  background: #fff;
  opacity: 0.35;
}
.group i { width: 14px; height: 14px; border-radius: 50%; }
.group b { display: block; }
.group small { color: var(--muted); }
.group .count { font-size: 22px; font-weight: 800; }
.to-center { margin: 18px 0 0; color: var(--muted); opacity: 0; }
.to-center b { color: var(--ink); }

.outro { align-items: flex-start; }
.outro h2 { 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(--accent);
  color: #fff;
  font-weight: 700;
}

@media (max-width: 760px) {
  .map-section { grid-template-columns: 1fr; align-content: center; gap: 14px; }
  .panel h2 { display: none; }
  .groups { grid-template-columns: 1fr 1fr; gap: 8px; }
  .group { padding: 8px 10px; grid-template-columns: 10px 1fr auto; }
  .group small { display: none; }
  .group .count { font-size: 17px; }
  .to-center { margin-top: 10px; font-size: 14px; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Bất động sản · Vị trí dự án</p>
  <h1>Mọi thứ trong bán kính 5 km</h1>
  <p class="lead">Cuộn xuống để xem quanh dự án có gì.</p>
</section>

<section class="map-section">
  <div class="map-wrap">
    <svg class="map" viewBox="0 0 1000 700" role="img" aria-label="Bản đồ tiện ích quanh dự án">
      <!-- Nền bản đồ: đất, sông, công viên, đường. Thay bằng bản đồ thật (SVG xuất từ Figma) cũng được. -->
      <rect width="1000" height="700" class="land" />
      <rect x="610" y="470" width="150" height="110" rx="18" class="park" />
      <rect x="120" y="60" width="130" height="90" rx="18" class="park" />
      <rect x="760" y="130" width="120" height="120" rx="18" class="park" />
      <path class="river" d="M1000 110 C 830 170, 770 300, 650 370 S 380 560, 0 610" />
      <g class="roads">
        <path class="road major" d="M0 380 H1000" />
        <path class="road major" d="M520 0 V700" />
        <path class="road" d="M120 700 L 920 40" />
        <path class="road minor" d="M0 230 H1000 M0 520 H1000 M300 0 V700 M760 0 V700" />
      </g>
      <text class="district" x="150" y="300">QUẬN 1</text>
      <text class="district" x="820" y="330">THỦ ĐỨC</text>
      <text class="district" x="300" y="470">BÌNH THẠNH</text>

      <!-- Vòng bán kính quanh dự án, JS vẽ -->
      <g class="rings"></g>
      <!-- Tuyến về trung tâm: xe chạy theo đường này -->
      <path id="route" class="route" d="M520 380 C 470 340, 420 300, 350 270 S 240 205, 180 170" />
      <g class="pins"></g>
      <g class="project">
        <circle class="pulse" cx="520" cy="380" r="18" />
        <circle class="dot" cx="520" cy="380" r="11" />
      </g>
      <circle class="car" r="9" cx="0" cy="0" />
      <g class="trip" opacity="0">
        <rect x="120" y="112" width="128" height="40" rx="20" />
        <text x="184" y="138" text-anchor="middle">15 phút</text>
      </g>
    </svg>
  </div>

  <aside class="panel">
    <p class="panel-kicker">An Phú Riverside</p>
    <h2>Quanh dự án</h2>
    <ul class="groups"></ul>
    <p class="to-center">Về trung tâm Quận 1: <b>15 phút</b> lái xe</p>
  </aside>
</section>

<section class="outro">
  <h2>Muốn tận mắt xem khu vực?</h2>
  <button type="button" class="btn">Đặt lịch đi xem dự án</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>
// Bản đồ tiện ích quanh dự án — bodev.vn/lab/ban-do-tien-ich
// Cần: gsap + ScrollTrigger + MotionPathPlugin.

gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);

// ---------- 1. Dữ liệu: sửa ở đây cho dự án của bạn ----------
const PROJECT = { x: 520, y: 380 };
// Bán kính vẽ trên bản đồ (đơn vị của viewBox) cho 1, 3, 5 km.
const RINGS = [
  { km: 1, r: 80 },
  { km: 3, r: 180 },
  { km: 5, r: 290 },
];
const GROUPS = [
  { id: "school", name: "Trường học", color: "var(--school)" },
  { id: "hospital", name: "Bệnh viện", color: "var(--hospital)" },
  { id: "mall", name: "Mua sắm", color: "var(--mall)" },
  { id: "park", name: "Công viên", color: "var(--park)" },
];
const PLACES = [
  { group: "school", name: "Trường quốc tế", x: 590, y: 330, minutes: 4 },
  { group: "school", name: "Đại học khu Đông", x: 380, y: 250, minutes: 9 },
  { group: "school", name: "Mầm non Hoa Sen", x: 470, y: 440, minutes: 3 },
  { group: "hospital", name: "BV Quốc tế", x: 660, y: 260, minutes: 7 },
  { group: "hospital", name: "Phòng khám 24h", x: 560, y: 450, minutes: 3 },
  { group: "mall", name: "Trung tâm thương mại", x: 430, y: 330, minutes: 5 },
  { group: "mall", name: "Chợ phường", x: 610, y: 410, minutes: 4 },
  { group: "mall", name: "Siêu thị lớn", x: 790, y: 440, minutes: 11 },
  { group: "park", name: "Công viên ven sông", x: 680, y: 520, minutes: 6 },
  { group: "park", name: "Công viên trung tâm", x: 185, y: 110, minutes: 15 },
  { group: "park", name: "Hồ điều hòa", x: 820, y: 185, minutes: 12 },
];

// ---------- 2. Dựng vòng, ghim, danh sách ----------
const NS = "http://www.w3.org/2000/svg";
function el(tag, attrs, parent) {
  const node = document.createElementNS(NS, tag);
  for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
  parent.appendChild(node);
  return node;
}

const ringsBox = document.querySelector(".rings");
const rings = RINGS.map(({ km, r }) => {
  const g = el("g", {}, ringsBox);
  el("circle", { class: "ring", cx: PROJECT.x, cy: PROJECT.y, r }, g);
  const label = el("text", { class: "ring-label", x: PROJECT.x + 8, y: PROJECT.y - r - 8 }, g);
  label.textContent = `${km} km`;
  return g;
});

const pinsBox = document.querySelector(".pins");
const pinsByGroup = {};
for (const place of PLACES) {
  const group = GROUPS.find((g) => g.id === place.group);
  // Nhóm ngoài giữ vị trí (transform tĩnh), nhóm trong để GSAP làm rơi xuống: hai transform không đá nhau.
  const at = el("g", { transform: `translate(${place.x} ${place.y})` }, pinsBox);
  const pin = el("g", { class: "pin" }, at);
  el("path", { class: "pin-head", d: "M0 0 C -12 -14, -12 -30, 0 -30 C 12 -30, 12 -14, 0 0 Z", style: `fill:${group.color}` }, pin);
  el("circle", { cx: 0, cy: -20, r: 4, fill: "#fff" }, pin);
  const label = el("text", { class: "pin-label", x: 12, y: -16 }, pin);
  label.textContent = place.name;
  (pinsByGroup[place.group] ??= []).push(pin);
}

const list = document.querySelector(".groups");
const items = GROUPS.map((g) => {
  const places = PLACES.filter((p) => p.group === g.id).sort((a, b) => a.minutes - b.minutes);
  const li = document.createElement("li");
  li.className = "group";
  const dot = document.createElement("i");
  dot.style.background = g.color;
  const text = document.createElement("span");
  const name = document.createElement("b");
  name.textContent = g.name;
  const nearest = document.createElement("small");
  nearest.textContent = `Gần nhất: ${places[0].name} · ${places[0].minutes} phút`;
  text.append(name, nearest);
  const count = document.createElement("span");
  count.className = "count";
  count.textContent = places.length;
  li.append(dot, text, count);
  list.appendChild(li);
  return li;
});

// ---------- 3. Timeline theo cuộn ----------
const route = document.querySelector("#route");
const routeLength = route.getTotalLength();
gsap.set(route, { strokeDasharray: routeLength, strokeDashoffset: routeLength });
gsap.set(".car", { opacity: 0 });

const tl = gsap.timeline({ defaults: { ease: "power2.out" } });
tl.from(".roads .road", { opacity: 0, duration: 0.6, stagger: 0.1 }, 0)
  .from(".project", { scale: 0, transformOrigin: "50% 50%", duration: 0.5, ease: "back.out(2)" }, 0.4)
  // svgOrigin: tâm tính theo toạ độ viewBox, nên vòng nào cũng phóng ra từ đúng vị trí dự án.
  .from(rings, { scale: 0, opacity: 0, svgOrigin: `${PROJECT.x} ${PROJECT.y}`, duration: 0.8, stagger: 0.25 }, 0.6);

// Mỗi nhóm tiện ích: ghim rơi xuống, dòng tương ứng trong bảng sáng lên.
GROUPS.forEach((g, i) => {
  const at = 1.8 + i * 0.9;
  tl.from(pinsByGroup[g.id], { y: -60, opacity: 0, duration: 0.5, stagger: 0.1, ease: "bounce.out" }, at).to(
    items[i],
    { opacity: 1, duration: 0.3 },
    at,
  );
});

// Tuyến về trung tâm: nét đường vẽ ra và xe chạy theo cùng một nhịp.
const tripAt = 1.8 + GROUPS.length * 0.9;
tl.set(".car", { opacity: 1 }, tripAt)
  .to(route, { strokeDashoffset: 0, duration: 1.2, ease: "none" }, tripAt)
  .to(".car", { motionPath: { path: route, align: route, alignOrigin: [0.5, 0.5] }, duration: 1.2, ease: "none" }, tripAt)
  .to(".trip", { opacity: 1, duration: 0.3 }, tripAt + 1.1)
  .to(".to-center", { opacity: 1, duration: 0.3 }, tripAt + 1.1);

// Chấm dự án nhấp nháy liên tục, không phụ thuộc cuộn.
const pulse = gsap.to(".project .pulse", { scale: 2.4, opacity: 0, transformOrigin: "50% 50%", duration: 1.6, repeat: -1, ease: "power1.out" });

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  tl.progress(1);
  pulse.kill();
} else {
  ScrollTrigger.create({
    trigger: ".map-section",
    start: "top top",
    end: "+=2600",
    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 Bất động sảnMở màn trang dự án