bodev.vn

Hành trình Hà Giang

Tuyến đường vẽ dần trên bản đồ, xe chạy theo, tới mỗi điểm dừng thì hiện ảnh và đồng hồ km nhảy số.

Cách hoạt động

  1. 1.Xe chạy từng chặng bằng MotionPathPlugin với start, end: mỗi tween chỉ chạy một đoạn của cùng một đường.
  2. 2.Vị trí điểm dừng trên tuyến (0 → 1) được đo tự động: dò điểm trên đường gần nhất với data-x, data-y. Sửa tuyến không phải tính lại.
  3. 3.Giữa hai chặng có quãng dừng để xem ảnh. Snap đưa về đúng các điểm dừng khi thả tay.
  4. 4.Chấm "đã tới" tính thẳng từ thời điểm của timeline, nên tua tới hay tua lui đều đúng.

Thử chỉnh

Tuyến đường khác
Sửa d của hai path tuyến đường, rồi đặt data-x, data-y của từng điểm dừng lên tuyến mới.
Dừng ngắm lâu hơn
Tăng STAY trong file JS.
Thêm điểm dừng
Copy một article.stop, đổi ảnh, toạ độ và số km.

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>Hành trình Hà Giang · 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: #132a22;
  --ground: #1b3a2f;
  --text: #f1ece0;
  --muted: #a4b3a8;
  --route: #f4c95d;
  --river: #4fb3a9;
}

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

.intro,
.outro {
  min-height: 70vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 40px clamp(20px, 6vw, 80px);
}
.kicker { margin: 0; color: var(--route); 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); }

.journey {
  height: 100vh;
  display: grid;
  grid-template-columns: 1fr 1.15fr;
  align-items: center;
  gap: clamp(16px, 3vw, 48px);
  padding: 24px clamp(20px, 5vw, 64px);
  overflow: hidden;
}
.map-wrap { position: relative; height: 100%; max-height: 90vh; display: grid; place-items: center; }
.map { height: 100%; max-width: 100%; border-radius: 20px; }
.map .ground { fill: var(--ground); }
.map .peak { fill: #24493b; }
.map .peak-snow { fill: #2d5747; }
.map .border { fill: none; stroke: #c96d5b; stroke-width: 2; stroke-dasharray: 10 6; }
.map .border-label { fill: #c96d5b; font-size: 11px; letter-spacing: 0.2em; }
.map .river { fill: none; stroke: var(--river); stroke-width: 5; opacity: 0.7; }
.map .route-base { fill: none; stroke: rgba(244, 201, 93, 0.18); stroke-width: 6; stroke-dasharray: 2 10; stroke-linecap: round; }
.map .route { fill: none; stroke: var(--route); stroke-width: 6; stroke-linecap: round; }
.map .stop-dot { fill: var(--bg); stroke: var(--route); stroke-width: 3; }
.map .stop-dot.reached { fill: var(--route); }
.map .stop-name { fill: var(--text); font-size: 14px; font-weight: 700; paint-order: stroke; stroke: var(--ground); stroke-width: 4px; }
.map .rider-ring { fill: rgba(244, 201, 93, 0.25); }
.map .rider-dot { fill: #fff; stroke: var(--route); stroke-width: 3; }

.odometer {
  position: absolute;
  left: 16px;
  bottom: 16px;
  margin: 0;
  padding: 8px 14px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.35);
  font-size: 15px;
  font-variant-numeric: tabular-nums;
}
.odometer .km { font-size: 22px; font-weight: 800; color: var(--route); }

.stops { position: relative; height: min(560px, 80vh); }
.stop { position: absolute; inset: 0; margin: 0; opacity: 0; }
.stop img { display: block; width: 100%; aspect-ratio: 3 / 2; height: auto; object-fit: cover; border-radius: 18px; }
.stop-meta { margin: 16px 0 0; color: var(--route); font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; }
.stop h2 { margin: 6px 0 0; font-size: clamp(28px, 3.4vw, 44px); line-height: 1.05; }
.stop p:last-child { max-width: 42ch; margin: 10px 0 0; color: var(--muted); line-height: 1.6; }

.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(--route);
  color: var(--bg);
  font-weight: 700;
}
.note { margin: 20px 0 0; font-size: 13px; color: var(--muted); }

@media (max-width: 760px) {
  .journey { grid-template-columns: 1fr; grid-template-rows: 44vh 1fr; gap: 12px; }
  .stops { height: 46vh; }
  .stop h2 { font-size: 24px; }
  .stop p:last-child { font-size: 14px; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Du lịch · Tour 3 ngày 2 đêm</p>
  <h1>Cung đường Hà Giang</h1>
  <p class="lead">Cuộn xuống để chạy hết 170 km.</p>
</section>

<section class="journey">
  <div class="map-wrap">
    <svg class="map" viewBox="0 0 600 700" role="img" aria-label="Bản đồ tuyến đường Hà Giang">
      <rect width="600" height="700" class="ground" />
      <!-- Núi đá: JS rải thêm các chóp núi vào đây -->
      <g class="peaks"></g>
      <path class="border" d="M40 70 C 160 40, 260 90, 360 60 S 520 40, 580 90" />
      <text class="border-label" x="60" y="56">BIÊN GIỚI</text>
      <path class="river" d="M250 700 C 270 640, 330 620, 320 560 S 280 500, 300 420" />
      <path class="river" d="M600 150 C 540 170, 500 210, 470 260 S 430 330, 460 420" />
      <!-- Tuyến đường: nét vẽ dần và xe chạy theo -->
      <path id="route" class="route-base" d="M300 640 C 290 560, 250 520, 262 470 C 272 420, 360 430, 392 380 C 424 330, 330 262, 342 204 C 352 168, 420 158, 470 182 C 512 200, 522 244, 500 286" />
      <path class="route" d="M300 640 C 290 560, 250 520, 262 470 C 272 420, 360 430, 392 380 C 424 330, 330 262, 342 204 C 352 168, 420 158, 470 182 C 512 200, 522 244, 500 286" />
      <g class="stop-dots"></g>
      <g class="rider">
        <circle r="13" class="rider-ring" />
        <circle r="6" class="rider-dot" />
      </g>
    </svg>
    <p class="odometer"><span class="km">0</span> km</p>
  </div>

  <!-- Mỗi điểm dừng: data-x, data-y là vị trí trên bản đồ (phải nằm trên tuyến đường). -->
  <div class="stops" aria-live="polite">
    <article class="stop" data-x="300" data-y="640" data-km="0">
      <img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/thanh-pho.webp" alt="Thành phố Hà Giang và sông Lô trong sương sớm" width="1200" height="806" />
      <p class="stop-meta">Ngày 1 · Km 0</p>
      <h2>Thành phố Hà Giang</h2>
      <p>Điểm xuất phát. Thuê xe máy, ăn bát phở sáng rồi lên đường.</p>
    </article>
    <article class="stop" data-x="262" data-y="470" data-km="45">
      <img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/quan-ba.webp" alt="Núi đôi giữa ruộng bậc thang ở Quản Bạ" width="1200" height="806" />
      <p class="stop-meta">Ngày 1 · Km 45</p>
      <h2>Quản Bạ</h2>
      <p>Qua Cổng Trời, nhìn xuống thung lũng có hai quả núi đôi tròn như vẽ.</p>
    </article>
    <article class="stop" data-x="392" data-y="380" data-km="100">
      <img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/yen-minh.webp" alt="Đường đèo uốn lượn qua rừng thông Yên Minh" width="1200" height="806" />
      <p class="stop-meta">Ngày 1 · Km 100</p>
      <h2>Yên Minh</h2>
      <p>Rừng thông hai bên, đường đèo lượn liên tục. Nghỉ đêm đầu tiên ở đây.</p>
    </article>
    <article class="stop" data-x="342" data-y="204" data-km="150">
      <img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/dong-van.webp" alt="Làng nhà trình tường giữa cao nguyên đá Đồng Văn" width="1200" height="806" />
      <p class="stop-meta">Ngày 2 · Km 150</p>
      <h2>Đồng Văn</h2>
      <p>Cao nguyên đá, nhà trình tường mái âm dương. Tối dạo phố cổ.</p>
    </article>
    <article class="stop" data-x="500" data-y="286" data-km="170">
      <img src="https://bodev.vn/lab-assets/hanh-trinh-ha-giang/ma-pi-leng.webp" alt="Đèo Mã Pí Lèng và sông Nho Quế xanh ngọc dưới hẻm vực" width="1200" height="806" />
      <p class="stop-meta">Ngày 3 · Km 170</p>
      <h2>Mã Pí Lèng</h2>
      <p>Con đèo đẹp nhất. Dưới vực là sông Nho Quế xanh ngọc.</p>
    </article>
  </div>
</section>

<section class="outro">
  <h2>Sẵn sàng lên đường?</h2>
  <button type="button" class="btn">Giữ chỗ tour tháng này</button>
  <p class="note">Ảnh trong demo là minh họa tạo bằng AI.</p>
</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>
// Hành trình Hà Giang — bodev.vn/lab/hanh-trinh-ha-giang
// Cần: gsap + ScrollTrigger + MotionPathPlugin.

gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);

const NS = "http://www.w3.org/2000/svg";
const route = document.querySelector(".route");
const routeBase = document.querySelector("#route");
const stops = [...document.querySelectorAll(".stop")];
const kmLabel = document.querySelector(".odometer .km");
const length = route.getTotalLength();

// ---------- 1. Núi đá trang trí: rải ngẫu nhiên nhưng cố định (seed), lần nào tải cũng giống nhau ----------
let seed = 7;
const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const peaks = document.querySelector(".peaks");
for (let i = 0; i < 70; i++) {
  const x = random() * 600;
  const y = 90 + random() * 610;
  const h = 18 + random() * 34;
  const w = h * (0.6 + random() * 0.4);
  const peak = document.createElementNS(NS, "path");
  peak.setAttribute("class", random() > 0.7 ? "peak-snow" : "peak");
  // Chóp núi đá vôi: đáy rộng, đỉnh tròn.
  peak.setAttribute("d", `M${x - w} ${y} Q${x - w * 0.3} ${y - h} ${x} ${y - h} Q${x + w * 0.3} ${y - h} ${x + w} ${y} Z`);
  peaks.appendChild(peak);
}

// ---------- 2. Vị trí mỗi điểm dừng trên tuyến (0 → 1) ----------
// Đo tự động: dò điểm gần nhất trên đường với toạ độ data-x, data-y. Sửa tuyến không phải tính lại tay.
function fractionAt(x, y) {
  const SAMPLES = 600;
  let best = 0;
  let bestDist = Infinity;
  for (let i = 0; i <= SAMPLES; i++) {
    const p = route.getPointAtLength((i / SAMPLES) * length);
    const d = (p.x - x) ** 2 + (p.y - y) ** 2;
    if (d < bestDist) [best, bestDist] = [i / SAMPLES, d];
  }
  return best;
}
const fractions = stops.map((s) => fractionAt(Number(s.dataset.x), Number(s.dataset.y)));

// Chấm và tên điểm dừng trên bản đồ.
const dotsBox = document.querySelector(".stop-dots");
const dots = stops.map((s, i) => {
  const x = Number(s.dataset.x);
  const y = Number(s.dataset.y);
  const dot = document.createElementNS(NS, "circle");
  dot.setAttribute("class", "stop-dot");
  dot.setAttribute("cx", x);
  dot.setAttribute("cy", y);
  dot.setAttribute("r", 7);
  const name = document.createElementNS(NS, "text");
  name.setAttribute("class", "stop-name");
  name.setAttribute("x", x + (i % 2 ? -14 : 14));
  name.setAttribute("y", y + 5);
  name.setAttribute("text-anchor", i % 2 ? "end" : "start");
  name.textContent = s.querySelector("h2").textContent;
  dotsBox.append(dot, name);
  return dot;
});

// ---------- 3. Timeline: chạy từng chặng, tới nơi thì dừng ngắm ----------
gsap.set(route, { strokeDasharray: length, strokeDashoffset: length });
gsap.set(stops[0], { opacity: 1 });
dots[0].classList.add("reached");
const odometer = { km: 0 };

const LEG = 1; // thời lượng một chặng
const STAY = 0.6; // dừng ngắm ở mỗi điểm
const tl = gsap.timeline({ defaults: { ease: "power1.inOut" } });
// Đặt xe ở điểm xuất phát.
tl.set(".rider", { motionPath: { path: routeBase, align: routeBase, alignOrigin: [0.5, 0.5], start: fractions[0], end: fractions[0] } }, 0);

stops.slice(1).forEach((stop, k) => {
  const i = k + 1;
  const at = k * (LEG + STAY) + STAY;
  const from = fractions[i - 1];
  const to = fractions[i];
  tl.to(".rider", { motionPath: { path: routeBase, align: routeBase, alignOrigin: [0.5, 0.5], start: from, end: to }, duration: LEG }, at)
    // Nét đường vẽ theo đúng đoạn xe vừa chạy.
    .fromTo(route, { strokeDashoffset: length * (1 - from) }, { strokeDashoffset: length * (1 - to), duration: LEG, immediateRender: false }, at)
    .to(odometer, { km: Number(stop.dataset.km), duration: LEG, onUpdate: () => (kmLabel.textContent = Math.round(odometer.km)) }, at)
    .to(stops[i - 1], { opacity: 0, y: -20, duration: 0.3 }, at)
    // Hiện đủ đúng lúc xe tới nơi: snap dừng ở thời điểm đó, thẻ không được lưng chừng.
    .fromTo(stops[i], { opacity: 0, y: 30 }, { opacity: 1, y: 0, duration: 0.4 }, at + LEG - 0.4);
});

// Chấm "đã tới" tính thẳng từ thời điểm hiện tại: tua tới hay tua lui đều đúng.
const arriveAt = stops.map((_, i) => i * (LEG + STAY));
tl.eventCallback("onUpdate", () => dots.forEach((dot, i) => dot.classList.toggle("reached", tl.time() >= arriveAt[i])));

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  // Không chạy: hiện cả tuyến, cho xem điểm cuối.
  tl.progress(1);
} else {
  ScrollTrigger.create({
    trigger: ".journey",
    start: "top top",
    end: () => "+=" + window.innerHeight * stops.length,
    pin: true,
    scrub: 1,
    animation: tl,
    // Thả tay là dừng đúng ở một điểm dừng.
    snap: {
      snapTo: arriveAt.map((t) => t / tl.duration()),
      inertia: false,
      directional: false, // về điểm dừng gần nhất, không nhảy quá một điểm theo hướng cuộn
      duration: { min: 0.2, max: 0.6 },
    },
  });
}

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.