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.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.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.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.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.