feat: enhance hotspot configuration with scale and initial pitch adjustments

This commit is contained in:
tuyenio
2026-06-24 15:22:39 +07:00
parent 035411493d
commit 464ce1e402
2 changed files with 181 additions and 21 deletions
+8 -2
View File
@@ -37,6 +37,7 @@ type Hotspot = {
pitch: number; pitch: number;
rotation?: number; rotation?: number;
nextYaw?: number; nextYaw?: number;
scale?: number;
}; };
type InfoMarker = { type InfoMarker = {
@@ -57,6 +58,7 @@ type TourScene = {
location: string; location: string;
image: string; image: string;
initialYaw: number; initialYaw: number;
initialPitch?: number;
mapPosition: { mapPosition: {
x: number; x: number;
y: number; y: number;
@@ -1145,7 +1147,9 @@ function TourExperience({
return; return;
} }
const direction = directionFromYawPitch(yaw, 0).normalize(); // Góc nhìn dọc mặc định của cảnh (ngẩng/cúi khi vừa vào cảnh); mặc định 0 = ngang tầm mắt.
const pitch = scene.initialPitch ?? 0;
const direction = directionFromYawPitch(yaw, pitch).normalize();
camera.position.copy(direction.multiplyScalar(-distance)); camera.position.copy(direction.multiplyScalar(-distance));
controls.target.set(0, 0, 0); controls.target.set(0, 0, 0);
controls.update(); controls.update();
@@ -1201,11 +1205,13 @@ function TourExperience({
y > -80 && y > -80 &&
y < clientHeight + 80; y < clientHeight + 80;
// `scale` (xa/gần) đặt trong dữ liệu cảnh: >1 = mũi tên to/gần hơn, <1 = nhỏ/xa hơn.
const arrowScale = (hotspot.scale ?? 1) * (isOnScreen ? 1 : 0.88);
element.style.opacity = isOnScreen ? "1" : "0"; element.style.opacity = isOnScreen ? "1" : "0";
element.style.pointerEvents = isOnScreen ? "auto" : "none"; element.style.pointerEvents = isOnScreen ? "auto" : "none";
element.style.left = `${x}px`; element.style.left = `${x}px`;
element.style.top = `${y}px`; element.style.top = `${y}px`;
element.style.transform = `translate(-50%, -50%) scale(${isOnScreen ? 1 : 0.88})`; element.style.transform = `translate(-50%, -50%) scale(${arrowScale})`;
}); });
scene.infoMarkers?.forEach((marker) => { scene.infoMarkers?.forEach((marker) => {
+171 -17
View File
@@ -16,7 +16,6 @@ type SceneSeed = {
location: string; location: string;
// Số thứ tự FILE ảnh gốc trên đĩa (01..26). Không nhất thiết trùng số cảnh hiển thị. // Số thứ tự FILE ảnh gốc trên đĩa (01..26). Không nhất thiết trùng số cảnh hiển thị.
imageFile: number; imageFile: number;
initialYaw?: number;
infoMarkers?: InfoMarker[]; infoMarkers?: InfoMarker[];
}; };
@@ -38,8 +37,8 @@ const seeds: SceneSeed[] = [
id: "gioi-thieu", id: "gioi-thieu",
title: "Giới thiệu", title: "Giới thiệu",
eyebrow: "Chùa Thái Lạc · Pháp Vân tự", eyebrow: "Chùa Thái Lạc · Pháp Vân tự",
yaw: 0, yaw: 145,
pitch: -2, pitch: 5,
iconType: "info", iconType: "info",
content: [ content: [
"Chùa Thái Lạc, tên chữ là Pháp Vân tự, là ngôi chùa cổ thờ Pháp Vân, vị thần Mây trong tín ngưỡng Tứ Pháp của cư dân nông nghiệp đồng bằng Bắc Bộ.", "Chùa Thái Lạc, tên chữ là Pháp Vân tự, là ngôi chùa cổ thờ Pháp Vân, vị thần Mây trong tín ngưỡng Tứ Pháp của cư dân nông nghiệp đồng bằng Bắc Bộ.",
@@ -162,22 +161,22 @@ const adjacency: Record<number, number[]> = {
// 19 cao hơn (y nhỏ hơn) cả 20 và 13. // 19 cao hơn (y nhỏ hơn) cả 20 và 13.
// • Band 12-21-22-23-6 (y=56) được giãn cách khỏi hàng 9-8-5 (y=72). // • Band 12-21-22-23-6 (y=56) được giãn cách khỏi hàng 9-8-5 (y=72).
const mapPositions: Record<number, { x: number; y: number }> = { const mapPositions: Record<number, { x: number; y: number }> = {
1: { x: 50, y: 90 }, 1: { x: 50, y: 100 },
2: { x: 32, y: 90 }, 2: { x: 32, y: 90 },
3: { x: 68, y: 90 }, 3: { x: 68, y: 90 },
4: { x: 68, y: 81 }, 4: { x: 68, y: 81 },
5: { x: 68, y: 72 }, 5: { x: 68, y: 72 },
6: { x: 68, y: 56 }, 6: { x: 68, y: 56 },
7: { x: 82, y: 76 }, 7: { x: 78, y: 76 },
8: { x: 50, y: 72 }, 8: { x: 50, y: 72 },
9: { x: 32, y: 72 }, 9: { x: 32, y: 72 },
10: { x: 18, y: 72 }, 10: { x: 16, y: 72 },
11: { x: 18, y: 60 }, 11: { x: 18, y: 58 },
12: { x: 32, y: 56 }, 12: { x: 32, y: 56 },
13: { x: 32, y: 40 }, 13: { x: 32, y: 40 },
14: { x: 32, y: 22 }, 14: { x: 32, y: 12 },
15: { x: 50, y: 22 }, 15: { x: 50, y: 12 },
16: { x: 68, y: 22 }, 16: { x: 68, y: 12 },
17: { x: 82, y: 34 }, 17: { x: 82, y: 34 },
18: { x: 64, y: 42 }, 18: { x: 64, y: 42 },
19: { x: 50, y: 32 }, 19: { x: 50, y: 32 },
@@ -200,17 +199,171 @@ const bearingBetween = (from: number, to: number) => {
return Math.round(deg); return Math.round(deg);
}; };
// ═════════════════════════════════════════════════════════════════════════════
// ⮕ BẢNG ĐIỀU CHỈNH HƯỚNG MŨI TÊN — SỬA TRỰC TIẾP CÁC SỐ Ở ĐÂY
// ─────────────────────────────────────────────────────────────────────────────
// Có 2 bảng để bạn tự chỉnh, đơn vị tất cả là ĐỘ:
// ① arrowConfig — chỉnh TỪNG mũi tên ở MỘT chỗ: yaw · pitch · rotation · nextYaw · scale
// ② sceneDefaultView — góc nhìn MẶC ĐỊNH của mỗi cảnh (camera tự quay về khi vừa vào)
//
// Quy ước góc NGANG (yaw):
// 0 = phía trước | 90 = bên phải | -90 = bên trái | 180 / -180 = phía sau
// (tăng dần theo chiều kim đồng hồ; dùng số bất kỳ, vd 45, -120…)
// Quy ước góc DỌC (pitch):
// 0 = ngang tầm mắt | số DƯƠNG = ngẩng lên | số ÂM = cúi xuống
//
// CÁCH CHỈNH: chạy `npm run dev`, mở cảnh cần sửa, đổi số ở dòng tương ứng, lưu file
// → trang tự cập nhật. ① khoá theo "A->B" (đứng ở cảnh A, mũi tên sang cảnh B);
// ② khoá theo số cảnh. Bỏ trống yaw ở ① → mũi tên đó tự tính lại theo mini-map.
// ═════════════════════════════════════════════════════════════════════════════
const DEFAULT_ARROW_PITCH = -8; // độ chúc xuống mặc định cho mọi mũi tên
// ① CẤU HÌNH TỪNG MŨI TÊN — tất cả tham số của một mũi tên gom vào MỘT dòng cho dễ chỉnh.
type ArrowAdjust = {
yaw?: number; // góc NGANG (trái/phải). Bỏ trống → tự tính theo mini-map.
pitch?: number; // góc DỌC (lên/xuống). Bỏ trống → DEFAULT_ARROW_PITCH.
rotation?: number; // xoay hình icon mũi tên (độ) — chỉnh chiều vẽ của mũi tên.
nextYaw?: number; // hướng camera quay về SAU khi bấm sang cảnh đích. Bỏ trống → bằng yaw.
scale?: number; // xa/gần (kích thước): 1 = mặc định, >1 to/gần hơn, <1 nhỏ/xa hơn.
};
// ┌─ VÍ DỤ MẪU — copy phần trong { } rồi dán đè vào mũi tên cần chỉnh ───────────┐
// │ Quay sang phải/trái: { yaw: 90 } { yaw: -90 } │
// │ Mũi tên TO hơn / NHỎ hơn: { yaw: 90, scale: 1.3 } { yaw: 90, scale: 0.7 } │
// │ Chếch XUỐNG / NGẨNG lên: { yaw: 90, pitch: -20 } { yaw: 90, pitch: 10 } │
// │ Xoay hình icon mũi tên: { yaw: 90, rotation: 45 } │
// │ Vào cảnh xong quay mặt khác:{ yaw: 90, nextYaw: 180 } │
// │ Kết hợp nhiều thứ cùng lúc: { yaw: 90, pitch: -15, scale: 1.2, nextYaw: 0 } │
// └─────────────────────────────────────────────────────────────────────────────┘
const arrowConfig: Record<string, ArrowAdjust> = {
// Cảnh 1 — Cổng chùa Thái Lạc
"1->2" : { yaw: 88, pitch: -2, rotation: 20 }, // → 2. Cổng chùa bên đường
"1->3" : { yaw: -170, pitch: -5, rotation: -10 }, // → 3. Đường vào cổng chùa
"1->8" : { yaw: 140, pitch: -6, rotation: 0 }, // → 8. Gác chuông Tam quan
// Cảnh 2 — Cổng chùa bên đường
"2->1" : { yaw: -90, pitch: -18, rotation: 0 }, // → 1. Cổng chùa Thái Lạc
"2->9" : { yaw: 140, pitch: -20, rotation: 0 }, // → 9. Sân chùa và đài Quan Âm
// Cảnh 3 — Đường vào cổng chùa
"3->1" : { yaw: 10, pitch: -18, rotation: 0 }, // → 1. Cổng chùa Thái Lạc
"3->4" : { yaw: 120, pitch: -18, rotation: 0 }, // → 4. Cổng vào khuôn viên
// Cảnh 4 — Cổng vào khuôn viên
"4->5" : { yaw: 120, pitch: -5, rotation: 0 }, // → 5. Vườn chùa
"4->7" : { yaw: 150, pitch: -8, rotation: 0 }, // → 7. Hồ nước cảnh quan
// Cảnh 5 — Vườn chùa
"5->4" : { yaw: -88, pitch: -15, rotation: 0 }, // → 4. Cổng vào khuôn viên
"5->6" : { yaw: 100, pitch: -18, rotation: 0 }, // → 6. Lối vào rợp bóng cây
"5->7" : { yaw: -120, pitch: -22, rotation: -20 }, // → 7. Hồ nước cảnh quan
"5->8" : { yaw: -60, pitch: -20, rotation: 30 }, // → 8. Gác chuông Tam quan
// Cảnh 6 — Lối vào rợp bóng cây
"6->5" : { yaw: 290, pitch: -26, rotation: -30 }, // → 5. Vườn chùa
"6->17" : { yaw: 32, pitch: -8, rotation: 0 }, // → 17. Nhà khách
// Cảnh 7 — Hồ nước cảnh quan
"7->4" : { yaw: 0, pitch: -18, rotation: -20 }, // → 4. Cổng vào khuôn viên
"7->5" : { yaw: 39, pitch: -18, rotation: 30 }, // → 5. Vườn chùa
// Cảnh 8 — Gác chuông Tam quan
"8->1" : { yaw: 313, pitch: -8, rotation: 0 }, // → 1. Cổng chùa Thái Lạc
"8->5" : { yaw: 210, pitch: -8, rotation: 0 }, // → 5. Vườn chùa
"8->9" : { yaw: 30, pitch: -8, rotation: 0 }, // → 9. Sân chùa và đài Quan Âm
"8->22" : { yaw: 115, pitch: -15, rotation: 0 }, // → 22. Thượng điện
// Cảnh 9 — Sân chùa và đài Quan Âm
"9->2" : { yaw: 300, pitch: -8, rotation: 0 }, // → 2. Cổng chùa bên đường
"9->8" : { yaw: 200, pitch: -13, rotation: 0 }, // → 8. Gác chuông Tam quan
"9->10" : { yaw: 23, pitch: -8, rotation: 0 }, // → 10. Đài Quan Âm lộ thiên
"9->12" : { yaw: 117, pitch: -8, rotation: 0 }, // → 12. Lối vào khu nhà Tổ
// Cảnh 10 — Đài Quan Âm lộ thiên
"10->9" : { yaw: 290, pitch: -18, rotation: 0 }, // → 9. Sân chùa và đài Quan Âm
"10->11": { yaw: 210, pitch: -22, rotation: 0 }, // → 11. Vườn tháp cổ
// Cảnh 11 — Vườn tháp cổ
"11->9" : { yaw: 231, pitch: -28, rotation: 0 }, // → 9. Sân chùa và đài Quan Âm
"11->10": { yaw: 350, pitch: -11, rotation: -20 }, // → 10. Đài Quan Âm lộ thiên
// Cảnh 12 — Lối vào khu nhà Tổ
"12->9" : { yaw: 292, pitch: -8, rotation: 0 }, // → 9. Sân chùa và đài Quan Âm
"12->13": { yaw: 122, pitch: -8, rotation: 0 }, // → 13. Sân nhà Tổ
// Cảnh 13 — Sân nhà Tổ
"13->12": { yaw: -30, pitch: -18, rotation: 0 }, // → 12. Lối vào khu nhà Tổ
"13->14": { yaw: -150, pitch: -18, rotation: 0 }, // → 14. Hiên nhà dưới giàn cây
// Cảnh 14 — Hiên nhà dưới giàn cây
"14->13": { yaw: 228, pitch: -12, rotation: 0 }, // → 13. Sân nhà Tổ
"14->15": { yaw: 115, pitch: -12, rotation: 0 }, // → 15. Sân sau nhà khách
// Cảnh 15 — Sân sau nhà khách
"15->14": { yaw: -70, pitch: -8, rotation: 0 }, // → 14. Hiên nhà dưới giàn cây
"15->16": { yaw: 115, pitch: -8, rotation: 0 }, // → 16. Sân trong giếng trời
// Cảnh 16 — Sân trong giếng trời
"16->15": { yaw: -22, pitch: -18, rotation: 0 }, // → 15. Sân sau nhà khách
"16->17": { yaw: 230, pitch: -8, rotation: 0 }, // → 17. Nhà khách
// Cảnh 17 — Nhà khách
"17->16": { yaw: -28, pitch: -18, rotation: 30 }, // → 16. Sân trong giếng trời
"17->19": { yaw: -90, pitch: -18, rotation: 0 }, // → 19. Sân thiên tỉnh
"17->6" : { yaw: -97, pitch: -18, rotation: -40 }, // → 6. Lối vào rợp bóng cây
// Cảnh 18 — Hành lang tượng cổ
"18->17": { yaw: -62, pitch: -8, rotation: 20 }, // → 17. Nhà khách
"18->19": { yaw: -80, pitch: -8, rotation: -20 }, // → 19. Sân thiên tỉnh
// Cảnh 19 — Sân thiên tỉnh
"19->17": { yaw: 205, pitch: -12, rotation: 0 }, // → 17. Nhà khách
"19->18": { yaw: 220, pitch: -12, rotation: 10 }, // → 18. Hành lang tượng cổ
"19->20": { yaw: 10, pitch: -12, rotation: -10 }, // → 20. Hành lang tượng và sân vườn
// Cảnh 20 — Hành lang tượng và sân vườn
"20->19": { yaw: -110, pitch: -18, rotation: 0 }, // → 19. Sân thiên tỉnh
"20->21": { yaw: 78, pitch: -18, rotation: 0 }, // → 21. Gian thờ bên trong
// Cảnh 21 — Gian thờ bên trong
"21->20": { yaw: 50, pitch: -22, rotation: 10 }, // → 20. Hành lang tượng và sân vườn
"21->22": { yaw: 190, pitch: -18, rotation: 0 }, // → 22. Thượng điện
// Cảnh 22 — Thượng điện
"22->21": { yaw: 40, pitch: -18, rotation: 0 }, // → 21. Gian thờ bên trong
"22->23": { yaw: 200, pitch: -18, rotation: 0 }, // → 23. Hậu đường
"22->8" : { yaw: 295, pitch: -18, rotation: 0 }, // → 8. Gác chuông Tam quan
// Cảnh 23 — Hậu đường
"23->22": { yaw: 30, pitch: -18, rotation: 0 }, // → 22. Thượng điện
};
// ③ Góc nhìn MẶC ĐỊNH của mỗi cảnh — camera tự quay về hướng này khi vừa vào cảnh.
// yaw = quay ngang (0 = hướng gốc của ảnh), pitch = ngẩng/cúi (0 = ngang tầm mắt).
const sceneDefaultView: Record<number, { yaw: number; pitch: number }> = {
1: { yaw: 140, pitch: 18 }, // Cổng chùa Thái Lạc
2: { yaw: 140, pitch: 10 }, // Cổng chùa bên đường
3: { yaw: 120, pitch: 0 }, // Đường vào cổng chùa
4: { yaw: 120, pitch: 10 }, // Cổng vào khuôn viên
5: { yaw: 120, pitch: 0 }, // Vườn chùa
6: { yaw: -80, pitch: -3 }, // Lối vào rợp bóng cây
7: { yaw: 110, pitch: 0 }, // Hồ nước cảnh quan
8: { yaw: 90, pitch: 0 }, // Gác chuông Tam quan
9: { yaw: 20, pitch: 8 }, // Sân chùa và đài Quan Âm
10: { yaw: 110, pitch: 0 }, // Đài Quan Âm lộ thiên
11: { yaw: 115, pitch: 0 }, // Vườn tháp cổ
12: { yaw: 120, pitch: 0 }, // Lối vào khu nhà Tổ
13: { yaw: 220, pitch: 0 }, // Sân nhà Tổ
14: { yaw: -150, pitch: 0 }, // Hiên nhà dưới giàn cây
15: { yaw: 130, pitch: 0 }, // Sân sau nhà khách
16: { yaw: 230, pitch: 0 }, // Sân trong giếng trời
17: { yaw: -60, pitch: 0 }, // Nhà khách
18: { yaw: -80, pitch: 0 }, // Hành lang tượng cổ
19: { yaw: 110, pitch: 0 }, // Sân thiên tỉnh
20: { yaw: 140, pitch: 0 }, // Hành lang tượng và sân vườn
21: { yaw: 130, pitch: 0 }, // Gian thờ bên trong
22: { yaw: 110, pitch: 0 }, // Thượng điện
23: { yaw: 150, pitch: 0 }, // Hậu đường
};
const scenes: TourScene[] = seeds.map((seed, index) => { const scenes: TourScene[] = seeds.map((seed, index) => {
const sceneNumber = index + 1; const sceneNumber = index + 1;
// Mũi tên hướng đi: mỗi đích đến trong đồ thị = một hotspot, đặt theo phương vị // Mỗi đích đến trong đồ thị = một mũi tên (hotspot). Góc lấy từ bảng arrowYaw/arrowPitch
// bản đồ để hướng mũi tên khớp với mini-map. Tinh chỉnh yaw trực quan khi chạy dev. // ở trên; nếu không có trong bảng thì tự tính theo mini-map (bearingBetween).
const hotspots: Hotspot[] = (adjacency[sceneNumber] ?? []).map((target) => ({ const hotspots: Hotspot[] = (adjacency[sceneNumber] ?? []).map((target) => {
const key = `${sceneNumber}->${target}`;
const cfg = arrowConfig[key] ?? {};
return {
targetId: sceneId(target), targetId: sceneId(target),
label: seeds[target - 1].title, label: seeds[target - 1].title,
yaw: bearingBetween(sceneNumber, target), yaw: cfg.yaw ?? bearingBetween(sceneNumber, target),
pitch: -8, pitch: cfg.pitch ?? DEFAULT_ARROW_PITCH,
})); rotation: cfg.rotation,
nextYaw: cfg.nextYaw,
scale: cfg.scale,
};
});
const defaultView = sceneDefaultView[sceneNumber] ?? { yaw: 0, pitch: 0 };
return { return {
id: sceneId(sceneNumber), id: sceneId(sceneNumber),
@@ -218,7 +371,8 @@ const scenes: TourScene[] = seeds.map((seed, index) => {
title: seed.title, title: seed.title,
location: seed.location, location: seed.location,
image: imageForFile(seed.imageFile), image: imageForFile(seed.imageFile),
initialYaw: seed.initialYaw ?? 0, initialYaw: defaultView.yaw,
initialPitch: defaultView.pitch,
mapPosition: mapPositions[sceneNumber], mapPosition: mapPositions[sceneNumber],
hotspots, hotspots,
infoMarkers: seed.infoMarkers, infoMarkers: seed.infoMarkers,