Compare commits
11 Commits
0e8af80f07
...
cf9f09eba0
| Author | SHA1 | Date | |
|---|---|---|---|
| cf9f09eba0 | |||
| e4eb4a81b2 | |||
| 5cc3c7b636 | |||
| cf360694d6 | |||
| 336e3ddb63 | |||
| 3ffa0d9455 | |||
| db1f76bd0c | |||
| 71179e2c78 | |||
| 68e7b4230f | |||
| d4892544e0 | |||
| 97644677ad |
@@ -41,3 +41,6 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# ảnh gốc trước khi nén (backup cục bộ)
|
||||||
|
/image-originals
|
||||||
|
|||||||
@@ -0,0 +1,761 @@
|
|||||||
|
# Tối ưu hiệu năng & sửa bản đồ phy-vr360 — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Loại bỏ đơ/giật/lag và làm bản đồ panel "Vị trí" hiển thị cân đối, đầy đủ, không bị khuất.
|
||||||
|
|
||||||
|
**Architecture:** Nén ảnh panorama 394MB → ~30MB bằng script `sharp` (backup gốc + sinh ảnh mờ blur-up), sửa `MiniMap` overview sang auto-fit, và tối ưu engine `VirtualTour.tsx` (render theo nhu cầu, bỏ `preserveDrawingBuffer`, hạ pixelRatio, preload lúc rảnh, blur-up khi tải).
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 / React 19, three.js 0.184, sharp (đã cài), TypeScript.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-24-toi-uu-phy-vr360-design.md`
|
||||||
|
|
||||||
|
**Lưu ý môi trường:** Shell là Git Bash trên Windows; chạy lệnh trong thư mục `phy-vr360`. Dự án **không có test runner** — kiểm chứng bằng `npx tsc --noEmit`, output của script, `npm run build`, và checklist thủ công. Mỗi commit kết thúc bằng trailer:
|
||||||
|
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Script nén ảnh + .gitignore
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/optimize-images.mjs`
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tạo script nén ảnh**
|
||||||
|
|
||||||
|
Create `scripts/optimize-images.mjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Nén ảnh panorama + modal, backup bản gốc, sinh ảnh mờ (LQIP).
|
||||||
|
// Chạy: node scripts/optimize-images.mjs (chạy lại nhiều lần an toàn — luôn nén từ backup gốc)
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const PANO_DIR = path.join(ROOT, "public/images/chua-thai-lac");
|
||||||
|
const MODAL_DIR = path.join(ROOT, "public/modal");
|
||||||
|
const BACKUP_DIR = path.join(ROOT, "image-originals");
|
||||||
|
const LQIP_OUT = path.join(ROOT, "src/app/tours/chua-thai-lac.lqip.ts");
|
||||||
|
|
||||||
|
const PANO_WIDTH = 6144;
|
||||||
|
const PANO_QUALITY = 80;
|
||||||
|
const LQIP_WIDTH = 32;
|
||||||
|
const MODAL_FILES = ["modal-1.png", "modal-2.png"];
|
||||||
|
|
||||||
|
const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
|
||||||
|
const ensureDir = (d) => fs.mkdir(d, { recursive: true });
|
||||||
|
const exists = (p) => fs.access(p).then(() => true).catch(() => false);
|
||||||
|
|
||||||
|
// Đảm bảo ảnh gốc đã được backup; luôn ĐỌC từ backup để không nén chồng.
|
||||||
|
async function sourceFrom(destPath, backupSubdir) {
|
||||||
|
const name = path.basename(destPath);
|
||||||
|
const backupPath = path.join(BACKUP_DIR, backupSubdir, name);
|
||||||
|
await ensureDir(path.dirname(backupPath));
|
||||||
|
if (!(await exists(backupPath))) {
|
||||||
|
await fs.copyFile(destPath, backupPath);
|
||||||
|
}
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizePanoramas() {
|
||||||
|
const files = (await fs.readdir(PANO_DIR)).filter((f) => /\.jpe?g$/i.test(f)).sort();
|
||||||
|
const lqip = {};
|
||||||
|
for (const f of files) {
|
||||||
|
const dest = path.join(PANO_DIR, f);
|
||||||
|
const src = await sourceFrom(dest, "chua-thai-lac");
|
||||||
|
const before = (await fs.stat(dest)).size;
|
||||||
|
|
||||||
|
const buf = await sharp(src)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
|
||||||
|
.toBuffer();
|
||||||
|
await fs.writeFile(dest, buf);
|
||||||
|
|
||||||
|
const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
|
||||||
|
lqip["/images/chua-thai-lac/" + f] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
|
||||||
|
|
||||||
|
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)} (-${Math.round((1 - buf.length / before) * 100)}%)`);
|
||||||
|
}
|
||||||
|
return lqip;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeModals() {
|
||||||
|
for (const f of MODAL_FILES) {
|
||||||
|
const dest = path.join(MODAL_DIR, f);
|
||||||
|
if (!(await exists(dest))) continue;
|
||||||
|
const src = await sourceFrom(dest, "modal");
|
||||||
|
const before = (await fs.stat(dest)).size;
|
||||||
|
const buf = await sharp(src).png({ compressionLevel: 9, palette: true, quality: 90, dither: 1 }).toBuffer();
|
||||||
|
await fs.writeFile(dest, buf);
|
||||||
|
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLqip(lqip) {
|
||||||
|
const entries = Object.entries(lqip)
|
||||||
|
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`)
|
||||||
|
.join("\n");
|
||||||
|
const content =
|
||||||
|
"// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.\n" +
|
||||||
|
"// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.\n" +
|
||||||
|
"export const lqip: Record<string, string> = {\n" +
|
||||||
|
entries +
|
||||||
|
"\n};\n";
|
||||||
|
await fs.writeFile(LQIP_OUT, content);
|
||||||
|
console.log("Wrote " + path.relative(ROOT, LQIP_OUT) + " (" + Object.keys(lqip).length + " entries)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lqip = await optimizePanoramas();
|
||||||
|
await optimizeModals();
|
||||||
|
await writeLqip(lqip);
|
||||||
|
console.log("Done.");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Thêm `/image-originals` vào .gitignore**
|
||||||
|
|
||||||
|
Append to `.gitignore` (sau dòng `.vercel` cuối file):
|
||||||
|
|
||||||
|
```
|
||||||
|
# ảnh gốc trước khi nén (backup cục bộ)
|
||||||
|
/image-originals
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Kiểm tra script parse được (không chạy nén)**
|
||||||
|
|
||||||
|
Run: `node --check scripts/optimize-images.mjs`
|
||||||
|
Expected: không in lỗi (exit 0).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/optimize-images.mjs .gitignore
|
||||||
|
git commit -m "feat: script nén ảnh panorama + LQIP
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Chạy nén ảnh + commit tài sản đã nén
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify (ghi đè): `public/images/chua-thai-lac/*.jpg`, `public/modal/modal-1.png`, `public/modal/modal-2.png`
|
||||||
|
- Create: `src/app/tours/chua-thai-lac.lqip.ts`
|
||||||
|
- Create (gitignored): `image-originals/...`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Chạy script nén**
|
||||||
|
|
||||||
|
Run: `node scripts/optimize-images.mjs`
|
||||||
|
Expected: in từng dòng `01.jpg: 22.64MB -> ~1.xMB (-9x%)` … và `Wrote src/app/tours/chua-thai-lac.lqip.ts (23 entries)` rồi `Done.`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Xác nhận dung lượng đã giảm**
|
||||||
|
|
||||||
|
Run: `du -sh public/images/chua-thai-lac && du -sh image-originals/chua-thai-lac`
|
||||||
|
Expected: `public/images/chua-thai-lac` ~25–35MB; `image-originals/chua-thai-lac` ~394MB (bản gốc đã backup).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Xác nhận LQIP đã sinh đúng số lượng**
|
||||||
|
|
||||||
|
Run: `grep -c "data:image/jpeg;base64" src/app/tours/chua-thai-lac.lqip.ts`
|
||||||
|
Expected: `23`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Xác nhận `image-originals` không bị git theo dõi**
|
||||||
|
|
||||||
|
Run: `git status --porcelain image-originals | head`
|
||||||
|
Expected: không có dòng nào (thư mục bị ignore).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Kiểm tra mắt vài ảnh** (thủ công)
|
||||||
|
|
||||||
|
Mở `public/images/chua-thai-lac/01.jpg`, `12.jpg`, `21.jpg` bằng trình xem ảnh. Expected: rõ nét, không vỡ hạt rõ rệt, đúng nội dung. Mở `public/modal/modal-1.png` — Expected: vẫn giữ trong suốt, không banding nặng. (Nếu modal banding rõ: chấp nhận hoặc khôi phục từ `image-originals/modal/` và bỏ phần modal trong script.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit ảnh đã nén + LQIP**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add public/images/chua-thai-lac src/app/tours/chua-thai-lac.lqip.ts public/modal
|
||||||
|
git commit -m "perf: nén panorama 394MB->~30MB + sinh LQIP, nén modal PNG
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Bản đồ "Vị trí" auto-fit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/components/VirtualTour.tsx` (hàm `MiniMap`, phần `getMapPosition`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Thay phép co cứng bằng auto-fit theo bao toàn bộ điểm**
|
||||||
|
|
||||||
|
Find (trong hàm `MiniMap`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { mapRoutes, sceneById, scenes } = tourConfig;
|
||||||
|
const isOverview = mode === "overview";
|
||||||
|
const nodeSize = compact ? 5.2 : 5.2;
|
||||||
|
const edgeInset = nodeSize / 2;
|
||||||
|
const clampMapPoint = (value: number) => THREE.MathUtils.clamp(value, edgeInset, 100 - edgeInset);
|
||||||
|
const compressOverviewY = (value: number) => 50 + (value - 50) * 0.86;
|
||||||
|
const getMapPosition = (scene: TourScene) => {
|
||||||
|
if (!scene.mapPosition || (!isOverview && !activeScene.mapPosition)) return null;
|
||||||
|
|
||||||
|
if (isOverview) {
|
||||||
|
return {
|
||||||
|
x: clampMapPoint(scene.mapPosition.x),
|
||||||
|
y: clampMapPoint(compressOverviewY(scene.mapPosition.y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { mapRoutes, sceneById, scenes } = tourConfig;
|
||||||
|
const isOverview = mode === "overview";
|
||||||
|
const nodeSize = compact ? 5.2 : 5.2;
|
||||||
|
const edgeInset = nodeSize / 2;
|
||||||
|
const clampMapPoint = (value: number) => THREE.MathUtils.clamp(value, edgeInset, 100 - edgeInset);
|
||||||
|
|
||||||
|
// Auto-fit overview: co toàn bộ điểm vào khung với lề an toàn để không bị cắt.
|
||||||
|
const OVERVIEW_MARGIN = 8;
|
||||||
|
const overviewPts = scenes
|
||||||
|
.map((s) => s.mapPosition)
|
||||||
|
.filter((p): p is { x: number; y: number } => Boolean(p));
|
||||||
|
const overviewBounds = {
|
||||||
|
minX: Math.min(...overviewPts.map((p) => p.x)),
|
||||||
|
maxX: Math.max(...overviewPts.map((p) => p.x)),
|
||||||
|
minY: Math.min(...overviewPts.map((p) => p.y)),
|
||||||
|
maxY: Math.max(...overviewPts.map((p) => p.y)),
|
||||||
|
};
|
||||||
|
const fitOverview = (value: number, min: number, max: number) =>
|
||||||
|
max - min < 0.0001
|
||||||
|
? 50
|
||||||
|
: OVERVIEW_MARGIN + ((value - min) / (max - min)) * (100 - 2 * OVERVIEW_MARGIN);
|
||||||
|
|
||||||
|
const getMapPosition = (scene: TourScene) => {
|
||||||
|
if (!scene.mapPosition || (!isOverview && !activeScene.mapPosition)) return null;
|
||||||
|
|
||||||
|
if (isOverview) {
|
||||||
|
return {
|
||||||
|
x: clampMapPoint(fitOverview(scene.mapPosition.x, overviewBounds.minX, overviewBounds.maxX)),
|
||||||
|
y: clampMapPoint(fitOverview(scene.mapPosition.y, overviewBounds.minY, overviewBounds.maxY)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Type-check**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit`
|
||||||
|
Expected: exit 0, không lỗi (đặc biệt không còn cảnh báo `compressOverviewY` unused vì đã xoá).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/components/VirtualTour.tsx
|
||||||
|
git commit -m "fix: bản đồ Vị trí auto-fit, không cắt điểm, căn giữa cân đối
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Bỏ preserveDrawingBuffer + hạ pixelRatio
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/components/VirtualTour.tsx` (cấu hình `WebGLRenderer` của tour, trong useEffect khởi tạo three)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Sửa cấu hình renderer**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: false,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.8));
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: false,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Type-check**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit`
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/components/VirtualTour.tsx
|
||||||
|
git commit -m "perf: bỏ preserveDrawingBuffer, hạ pixelRatio cap xuống 1.5
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Render theo nhu cầu (on-demand) + throttle cameraYaw
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/components/VirtualTour.tsx` (khai báo ref ở đầu component; hàm `animate`; đăng ký listener `controls`)
|
||||||
|
|
||||||
|
Mục tiêu: khi người dùng đứng yên, không vẽ thừa và không gọi `setCameraYaw` mỗi frame (đang gây React re-render 60 lần/giây).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Thêm 2 ref điều phối render**
|
||||||
|
|
||||||
|
Find (cụm ref gần đầu component `TourExperience`, ngay sau dòng `const currentFovRef = useRef(DEFAULT_FOV);`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const currentFovRef = useRef(DEFAULT_FOV);
|
||||||
|
const targetFovRef = useRef(DEFAULT_FOV);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const currentFovRef = useRef(DEFAULT_FOV);
|
||||||
|
const targetFovRef = useRef(DEFAULT_FOV);
|
||||||
|
const needsRenderRef = useRef(true);
|
||||||
|
const lastYawRef = useRef(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Đăng ký listener `controls 'change'` để bật cờ render**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handleControlsChange = () => {
|
||||||
|
needsRenderRef.current = true;
|
||||||
|
};
|
||||||
|
controls.addEventListener("change", handleControlsChange);
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Gỡ listener khi cleanup**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
renderer.setAnimationLoop(null);
|
||||||
|
controls.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
controls.removeEventListener("change", handleControlsChange);
|
||||||
|
renderer.setAnimationLoop(null);
|
||||||
|
controls.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Viết lại `animate` để vẽ theo nhu cầu**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const animate = () => {
|
||||||
|
const targetFov = targetFovRef.current;
|
||||||
|
|
||||||
|
if (Math.abs(currentFovRef.current - targetFov) > 0.01) {
|
||||||
|
currentFovRef.current += (targetFov - currentFovRef.current) * 0.16;
|
||||||
|
camera.fov = currentFovRef.current;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
} else if (camera.fov !== targetFov) {
|
||||||
|
currentFovRef.current = targetFov;
|
||||||
|
camera.fov = targetFov;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vrModeRef.current) {
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update camera yaw for mini-map rotation
|
||||||
|
const direction = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(direction);
|
||||||
|
const yaw = yawFromDirection(direction);
|
||||||
|
setCameraYaw(yaw);
|
||||||
|
|
||||||
|
updateHotspots();
|
||||||
|
|
||||||
|
renderer.render(threeScene, camera);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const animate = () => {
|
||||||
|
const targetFov = targetFovRef.current;
|
||||||
|
const fovAnimating = Math.abs(currentFovRef.current - targetFov) > 0.01;
|
||||||
|
|
||||||
|
if (fovAnimating) {
|
||||||
|
currentFovRef.current += (targetFov - currentFovRef.current) * 0.16;
|
||||||
|
camera.fov = currentFovRef.current;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
} else if (camera.fov !== targetFov) {
|
||||||
|
currentFovRef.current = targetFov;
|
||||||
|
camera.fov = targetFov;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
needsRenderRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// controls.update() trả về true khi camera vừa thay đổi (kể cả damping đang lắng).
|
||||||
|
const controlsChanged = vrModeRef.current ? false : controls.update();
|
||||||
|
const transitionActive = transitionFrameRef.current !== null;
|
||||||
|
const active =
|
||||||
|
needsRenderRef.current ||
|
||||||
|
fovAnimating ||
|
||||||
|
controlsChanged ||
|
||||||
|
controls.autoRotate ||
|
||||||
|
vrModeRef.current ||
|
||||||
|
transitionActive;
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
needsRenderRef.current = false;
|
||||||
|
|
||||||
|
// Cập nhật yaw cho mini-map — chỉ setState khi đổi đủ lớn để tránh re-render thừa.
|
||||||
|
const direction = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(direction);
|
||||||
|
const yaw = yawFromDirection(direction);
|
||||||
|
if (Math.abs(normalizeYaw(yaw - lastYawRef.current)) > 0.15) {
|
||||||
|
lastYawRef.current = yaw;
|
||||||
|
setCameraYaw(yaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHotspots();
|
||||||
|
renderer.render(threeScene, camera);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit`
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Kiểm tra hành vi thủ công** (`npm run dev`)
|
||||||
|
|
||||||
|
Mở tour. Expected: kéo xoay mượt; thả tay cảnh đứng yên (không nhấp nháy); zoom (cuộn chuột) mượt; auto-rotate quay đều; chuyển cảnh qua mũi tên hoạt động; mini-map radar vẫn xoay theo góc nhìn; bật VR (trên máy có cảm biến) vẫn cập nhật. Không có cảnh "đứng hình" sau tương tác.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/components/VirtualTour.tsx
|
||||||
|
git commit -m "perf: render theo nhu cầu + throttle cameraYaw (giảm tải khi đứng yên)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Preload thông minh (lùi xuống lúc rảnh)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/components/VirtualTour.tsx` (helper module-level `scheduleIdle`; hàm `preloadTourScene`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Thêm helper `scheduleIdle` ở cấp module**
|
||||||
|
|
||||||
|
Find (helper module-level, ngay sau hàm `directionFromYawPitch`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
function directionFromYawPitch(yaw: number, pitch: number) {
|
||||||
|
const yawRad = THREE.MathUtils.degToRad(yaw);
|
||||||
|
const pitchRad = THREE.MathUtils.degToRad(pitch);
|
||||||
|
const cosPitch = Math.cos(pitchRad);
|
||||||
|
|
||||||
|
return new THREE.Vector3(
|
||||||
|
Math.sin(yawRad) * cosPitch,
|
||||||
|
Math.sin(pitchRad),
|
||||||
|
-Math.cos(yawRad) * cosPitch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add immediately after it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
type IdleWindow = typeof window & {
|
||||||
|
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lên lịch chạy lúc trình duyệt rảnh (fallback setTimeout) — dùng cho preload nền.
|
||||||
|
function scheduleIdle(cb: () => void) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = window as IdleWindow;
|
||||||
|
if (w.requestIdleCallback) {
|
||||||
|
w.requestIdleCallback(cb, { timeout: 2000 });
|
||||||
|
} else {
|
||||||
|
window.setTimeout(cb, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Cảnh hiện tại tải ngay, cảnh kề preload lúc rảnh**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const preloadTourScene = useCallback(
|
||||||
|
(scene: TourScene) => {
|
||||||
|
const preloadImage = (image: string) => {
|
||||||
|
loadPanoramaTexture(image)
|
||||||
|
.then((texture) => warmPanoramaTexture(image, texture))
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
preloadImage(scene.image);
|
||||||
|
scene.hotspots.forEach((hotspot) => {
|
||||||
|
const targetScene = tourConfig.sceneById.get(hotspot.targetId);
|
||||||
|
|
||||||
|
if (targetScene) {
|
||||||
|
preloadImage(targetScene.image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[tourConfig, warmPanoramaTexture],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const preloadTourScene = useCallback(
|
||||||
|
(scene: TourScene) => {
|
||||||
|
const preloadImage = (image: string) => {
|
||||||
|
loadPanoramaTexture(image)
|
||||||
|
.then((texture) => warmPanoramaTexture(image, texture))
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cảnh hiện tại: tải ngay. Cảnh kề: lùi xuống lúc rảnh để không nghẽn khi vừa vào.
|
||||||
|
preloadImage(scene.image);
|
||||||
|
scheduleIdle(() => {
|
||||||
|
scene.hotspots.forEach((hotspot) => {
|
||||||
|
const targetScene = tourConfig.sceneById.get(hotspot.targetId);
|
||||||
|
|
||||||
|
if (targetScene) {
|
||||||
|
preloadImage(targetScene.image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[tourConfig, warmPanoramaTexture],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Type-check**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit`
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/components/VirtualTour.tsx
|
||||||
|
git commit -m "perf: preload cảnh kề lúc trình duyệt rảnh
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Blur-up (LQIP) khi tải cảnh
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/components/VirtualTour.tsx` (import LQIP; helper `loadLqipTexture`; chèn vào `runSceneTransition` cho cả nhánh chuyển cảnh và nhánh tải cảnh đầu)
|
||||||
|
|
||||||
|
Phụ thuộc: `src/app/tours/chua-thai-lac.lqip.ts` (đã sinh ở Task 2) và `needsRenderRef` (đã thêm ở Task 5).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Import LQIP**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac";
|
||||||
|
import { lqip } from "@/app/tours/chua-thai-lac.lqip";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Thêm cache + loader cho texture mờ (cấp module)**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const panoramaTextureCache = new Map<string, THREE.Texture>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Add immediately before it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Texture ảnh mờ (blur-up) tạo từ data-URI base64 — hiện gần như tức thì khi cảnh đang tải.
|
||||||
|
const lqipTextureCache = new Map<string, THREE.Texture>();
|
||||||
|
function loadLqipTexture(image: string) {
|
||||||
|
const data = lqip[image];
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cached = lqipTextureCache.get(image);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const texture = new THREE.TextureLoader().load(data);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
lqipTextureCache.set(image, texture);
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Blur-up cho nhánh chuyển cảnh (texture chưa cache)**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
} else {
|
||||||
|
loadPanoramaTexture(scene.image, (progress) => {
|
||||||
|
if (transitionTokenRef.current === token) {
|
||||||
|
setLoadProgress(progress);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
} else {
|
||||||
|
const lqipTexture = loadLqipTexture(scene.image);
|
||||||
|
if (lqipTexture) {
|
||||||
|
transitionMaterial.map = lqipTexture;
|
||||||
|
transitionMaterial.needsUpdate = true;
|
||||||
|
}
|
||||||
|
loadPanoramaTexture(scene.image, (progress) => {
|
||||||
|
if (transitionTokenRef.current === token) {
|
||||||
|
setLoadProgress(progress);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
(Khi ảnh nét tải xong, `prepareTransitionTexture` thay `transitionMaterial.map` bằng ảnh nét — chuyển mờ→nét trên cùng cảnh.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Blur-up cho nhánh tải cảnh đầu (initial load)**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
setIsLoading(!initialCachedTexture);
|
||||||
|
setLoadProgress(initialCachedTexture ? 100 : 0);
|
||||||
|
loadTexture
|
||||||
|
.then((texture) => {
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
setIsLoading(!initialCachedTexture);
|
||||||
|
setLoadProgress(initialCachedTexture ? 100 : 0);
|
||||||
|
if (!initialCachedTexture) {
|
||||||
|
const lqipTexture = loadLqipTexture(scene.image);
|
||||||
|
if (lqipTexture) {
|
||||||
|
material.map = lqipTexture;
|
||||||
|
material.opacity = 1;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
needsRenderRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTexture
|
||||||
|
.then((texture) => {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Type-check**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit`
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Kiểm tra thủ công** (`npm run dev`, mở DevTools → Network → Slow 3G để thấy rõ)
|
||||||
|
|
||||||
|
Expected: khi vào cảnh chưa tải, hiện ảnh **mờ** ngay tức thì rồi sắc nét dần (không còn màn trắng/đen chờ). Chuyển cảnh tới cảnh chưa tải cũng thấy mờ→nét.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/components/VirtualTour.tsx
|
||||||
|
git commit -m "perf: ảnh mờ blur-up (LQIP) khi tải cảnh
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Kiểm tra tổng thể (build + checklist)
|
||||||
|
|
||||||
|
**Files:** không sửa — chỉ kiểm chứng.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build production**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: build thành công, không lỗi TypeScript/ESLint chặn build.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: không có lỗi (cảnh báo nhẹ chấp nhận được).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Checklist thủ công** (`npm run dev`)
|
||||||
|
|
||||||
|
Xác nhận từng mục — Expected: tất cả PASS:
|
||||||
|
- Trang chủ/welcome vào tour nhanh, ảnh đầu tiên hiện mờ rồi nét.
|
||||||
|
- Kéo xoay, zoom mượt; đứng yên không giật/nhấp nháy.
|
||||||
|
- Auto-rotate quay đều; tắt thì dừng vẽ thừa.
|
||||||
|
- Mũi tên hướng đi bấm chuyển cảnh đúng; mini-map radar xoay theo.
|
||||||
|
- Panel "Vị trí": đủ 23 điểm, căn giữa, **không điểm nào bị cắt** ở mép.
|
||||||
|
- Mở info marker, modal hiển thị đúng (ảnh modal không vỡ).
|
||||||
|
- (Nếu có thiết bị cảm biến) VR mode hoạt động.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit (nếu có chỉnh sửa nhỏ phát sinh từ kiểm tra)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: hoàn tất tối ưu hiệu năng & bản đồ phy-vr360
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (đã đối chiếu với spec)
|
||||||
|
|
||||||
|
- **Spec Phần A** (nén 6144/q80, backup gốc, modal PNG, LQIP) → Task 1–2. ✓
|
||||||
|
- **Spec Phần B** (auto-fit, lề 8%) → Task 3. ✓
|
||||||
|
- **Spec Phần C-1** render on-demand → Task 5; **C-2** bỏ preserveDrawingBuffer → Task 4; **C-3** pixelRatio 1.5 → Task 4; **C-4** preload idle → Task 6; **C-5** blur-up → Task 7. ✓
|
||||||
|
- **Phase 2** (GLB) cố ý ngoài phạm vi. ✓
|
||||||
|
- Type nhất quán: `needsRenderRef` định nghĩa ở Task 5 và dùng lại ở Task 7; `loadLqipTexture`/`lqip` khoá theo `scene.image` (đường dẫn `/images/chua-thai-lac/NN.jpg`) khớp với khoá script sinh ra ở Task 1. ✓
|
||||||
|
```
|
||||||
|
Before Width: | Height: | Size: 22 MiB After Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 21 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 23 MiB After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 19 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 19 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 10 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 10 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 14 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 666 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
// Nén ảnh panorama + modal, backup bản gốc, sinh ảnh mờ (LQIP).
|
||||||
|
// Chạy: node scripts/optimize-images.mjs (chạy lại nhiều lần an toàn — luôn nén từ backup gốc)
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const PANO_DIR = path.join(ROOT, "public/images/chua-thai-lac");
|
||||||
|
const MODAL_DIR = path.join(ROOT, "public/modal");
|
||||||
|
const BACKUP_DIR = path.join(ROOT, "image-originals");
|
||||||
|
const LQIP_OUT = path.join(ROOT, "src/app/tours/chua-thai-lac.lqip.ts");
|
||||||
|
|
||||||
|
const PANO_WIDTH = 6144;
|
||||||
|
const PANO_QUALITY = 80;
|
||||||
|
const LQIP_WIDTH = 32;
|
||||||
|
const MODAL_FILES = ["modal-1.png", "modal-2.png"];
|
||||||
|
|
||||||
|
const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
|
||||||
|
const ensureDir = (d) => fs.mkdir(d, { recursive: true });
|
||||||
|
const exists = (p) => fs.access(p).then(() => true).catch(() => false);
|
||||||
|
|
||||||
|
// Đảm bảo ảnh gốc đã được backup; luôn ĐỌC từ backup để không nén chồng.
|
||||||
|
async function sourceFrom(destPath, backupSubdir) {
|
||||||
|
const name = path.basename(destPath);
|
||||||
|
const backupPath = path.join(BACKUP_DIR, backupSubdir, name);
|
||||||
|
await ensureDir(path.dirname(backupPath));
|
||||||
|
if (!(await exists(backupPath))) {
|
||||||
|
await fs.copyFile(destPath, backupPath);
|
||||||
|
}
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizePanoramas() {
|
||||||
|
const files = (await fs.readdir(PANO_DIR)).filter((f) => /\.jpe?g$/i.test(f)).sort();
|
||||||
|
const lqip = {};
|
||||||
|
for (const f of files) {
|
||||||
|
const dest = path.join(PANO_DIR, f);
|
||||||
|
const src = await sourceFrom(dest, "chua-thai-lac");
|
||||||
|
const before = (await fs.stat(dest)).size;
|
||||||
|
|
||||||
|
const buf = await sharp(src)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
|
||||||
|
.toBuffer();
|
||||||
|
await fs.writeFile(dest, buf);
|
||||||
|
|
||||||
|
const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
|
||||||
|
lqip["/images/chua-thai-lac/" + f] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
|
||||||
|
|
||||||
|
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)} (-${Math.round((1 - buf.length / before) * 100)}%)`);
|
||||||
|
}
|
||||||
|
return lqip;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeModals() {
|
||||||
|
for (const f of MODAL_FILES) {
|
||||||
|
const dest = path.join(MODAL_DIR, f);
|
||||||
|
if (!(await exists(dest))) continue;
|
||||||
|
const src = await sourceFrom(dest, "modal");
|
||||||
|
const before = (await fs.stat(dest)).size;
|
||||||
|
const buf = await sharp(src).png({ compressionLevel: 9, palette: true, quality: 90, dither: 1 }).toBuffer();
|
||||||
|
await fs.writeFile(dest, buf);
|
||||||
|
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLqip(lqip) {
|
||||||
|
const entries = Object.entries(lqip)
|
||||||
|
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`)
|
||||||
|
.join("\n");
|
||||||
|
const content =
|
||||||
|
"// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.\n" +
|
||||||
|
"// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.\n" +
|
||||||
|
"export const lqip: Record<string, string> = {\n" +
|
||||||
|
entries +
|
||||||
|
"\n};\n";
|
||||||
|
await fs.writeFile(LQIP_OUT, content);
|
||||||
|
console.log("Wrote " + path.relative(ROOT, LQIP_OUT) + " (" + Object.keys(lqip).length + " entries)");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lqip = await optimizePanoramas();
|
||||||
|
await optimizeModals();
|
||||||
|
await writeLqip(lqip);
|
||||||
|
console.log("Done.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("optimize-images failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|||||||
import Lottie, { type LottieRefCurrentProps } from "lottie-react";
|
import Lottie, { type LottieRefCurrentProps } from "lottie-react";
|
||||||
import letterAnimation from "@/letter.json";
|
import letterAnimation from "@/letter.json";
|
||||||
import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac";
|
import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac";
|
||||||
|
import { lqip } from "@/app/tours/chua-thai-lac.lqip";
|
||||||
|
|
||||||
type SceneId = string;
|
type SceneId = string;
|
||||||
|
|
||||||
@@ -193,6 +194,23 @@ const defaultTourConfig = chuaThaiLacTourConfig;
|
|||||||
|
|
||||||
const getTourConfig = (tourId?: string) => toursById[tourId ?? ""] ?? defaultTourConfig;
|
const getTourConfig = (tourId?: string) => toursById[tourId ?? ""] ?? defaultTourConfig;
|
||||||
|
|
||||||
|
// Texture ảnh mờ (blur-up) tạo từ data-URI base64 — hiện gần như tức thì khi cảnh đang tải.
|
||||||
|
const lqipTextureCache = new Map<string, THREE.Texture>();
|
||||||
|
function loadLqipTexture(image: string) {
|
||||||
|
const data = lqip[image];
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cached = lqipTextureCache.get(image);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const texture = new THREE.TextureLoader().load(data);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
lqipTextureCache.set(image, texture);
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
const panoramaTextureCache = new Map<string, THREE.Texture>();
|
const panoramaTextureCache = new Map<string, THREE.Texture>();
|
||||||
const panoramaTexturePromises = new Map<string, Promise<THREE.Texture>>();
|
const panoramaTexturePromises = new Map<string, Promise<THREE.Texture>>();
|
||||||
const panoramaTextureProgress = new Map<string, number>();
|
const panoramaTextureProgress = new Map<string, number>();
|
||||||
@@ -294,6 +312,23 @@ function directionFromYawPitch(yaw: number, pitch: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IdleWindow = typeof window & {
|
||||||
|
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lên lịch chạy lúc trình duyệt rảnh (fallback setTimeout) — dùng cho preload nền.
|
||||||
|
function scheduleIdle(cb: () => void) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = window as IdleWindow;
|
||||||
|
if (w.requestIdleCallback) {
|
||||||
|
w.requestIdleCallback(cb, { timeout: 2000 });
|
||||||
|
} else {
|
||||||
|
window.setTimeout(cb, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function VirtualTour({
|
export default function VirtualTour({
|
||||||
tourId = "chua-thai-lac",
|
tourId = "chua-thai-lac",
|
||||||
}: {
|
}: {
|
||||||
@@ -958,6 +993,8 @@ function TourExperience({
|
|||||||
const transitionLockRef = useRef(false);
|
const transitionLockRef = useRef(false);
|
||||||
const currentFovRef = useRef(DEFAULT_FOV);
|
const currentFovRef = useRef(DEFAULT_FOV);
|
||||||
const targetFovRef = useRef(DEFAULT_FOV);
|
const targetFovRef = useRef(DEFAULT_FOV);
|
||||||
|
const needsRenderRef = useRef(true);
|
||||||
|
const lastYawRef = useRef(0);
|
||||||
const lastPinchDistanceRef = useRef<number | null>(null);
|
const lastPinchDistanceRef = useRef<number | null>(null);
|
||||||
const infoMarkerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
const infoMarkerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||||
const [activeInfoMarker, setActiveInfoMarker] = useState<InfoMarker | null>(null);
|
const [activeInfoMarker, setActiveInfoMarker] = useState<InfoMarker | null>(null);
|
||||||
@@ -1280,13 +1317,16 @@ function TourExperience({
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cảnh hiện tại: tải ngay. Cảnh kề: lùi xuống lúc rảnh để không nghẽn khi vừa vào.
|
||||||
preloadImage(scene.image);
|
preloadImage(scene.image);
|
||||||
scene.hotspots.forEach((hotspot) => {
|
scheduleIdle(() => {
|
||||||
const targetScene = tourConfig.sceneById.get(hotspot.targetId);
|
scene.hotspots.forEach((hotspot) => {
|
||||||
|
const targetScene = tourConfig.sceneById.get(hotspot.targetId);
|
||||||
|
|
||||||
if (targetScene) {
|
if (targetScene) {
|
||||||
preloadImage(targetScene.image);
|
preloadImage(targetScene.image);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[tourConfig, warmPanoramaTexture],
|
[tourConfig, warmPanoramaTexture],
|
||||||
@@ -1302,11 +1342,10 @@ function TourExperience({
|
|||||||
const renderer = new THREE.WebGLRenderer({
|
const renderer = new THREE.WebGLRenderer({
|
||||||
antialias: true,
|
antialias: true,
|
||||||
alpha: false,
|
alpha: false,
|
||||||
preserveDrawingBuffer: true,
|
|
||||||
powerPreference: "high-performance",
|
powerPreference: "high-performance",
|
||||||
});
|
});
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.8));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
renderer.setSize(mount.clientWidth, mount.clientHeight);
|
renderer.setSize(mount.clientWidth, mount.clientHeight);
|
||||||
mount.appendChild(renderer.domElement);
|
mount.appendChild(renderer.domElement);
|
||||||
rendererRef.current = renderer;
|
rendererRef.current = renderer;
|
||||||
@@ -1429,6 +1468,11 @@ function TourExperience({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleControlsChange = () => {
|
||||||
|
needsRenderRef.current = true;
|
||||||
|
};
|
||||||
|
controls.addEventListener("change", handleControlsChange);
|
||||||
|
|
||||||
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
|
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
renderer.domElement.addEventListener("touchstart", handleTouchStart, { passive: false });
|
renderer.domElement.addEventListener("touchstart", handleTouchStart, { passive: false });
|
||||||
renderer.domElement.addEventListener("touchmove", handleTouchMove, { passive: false });
|
renderer.domElement.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
@@ -1451,13 +1495,15 @@ function TourExperience({
|
|||||||
camera.aspect = width / height;
|
camera.aspect = width / height;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
renderer.setSize(width, height);
|
renderer.setSize(width, height);
|
||||||
|
needsRenderRef.current = true;
|
||||||
updateHotspots();
|
updateHotspots();
|
||||||
};
|
};
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
const targetFov = targetFovRef.current;
|
const targetFov = targetFovRef.current;
|
||||||
|
const fovAnimating = Math.abs(currentFovRef.current - targetFov) > 0.01;
|
||||||
|
|
||||||
if (Math.abs(currentFovRef.current - targetFov) > 0.01) {
|
if (fovAnimating) {
|
||||||
currentFovRef.current += (targetFov - currentFovRef.current) * 0.16;
|
currentFovRef.current += (targetFov - currentFovRef.current) * 0.16;
|
||||||
camera.fov = currentFovRef.current;
|
camera.fov = currentFovRef.current;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
@@ -1465,20 +1511,35 @@ function TourExperience({
|
|||||||
currentFovRef.current = targetFov;
|
currentFovRef.current = targetFov;
|
||||||
camera.fov = targetFov;
|
camera.fov = targetFov;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
|
needsRenderRef.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!vrModeRef.current) {
|
// controls.update() trả về true khi camera vừa thay đổi (kể cả damping đang lắng).
|
||||||
controls.update();
|
const controlsChanged = vrModeRef.current ? false : controls.update();
|
||||||
}
|
const transitionActive = transitionFrameRef.current !== null;
|
||||||
|
const active =
|
||||||
|
needsRenderRef.current ||
|
||||||
|
fovAnimating ||
|
||||||
|
controlsChanged ||
|
||||||
|
controls.autoRotate ||
|
||||||
|
vrModeRef.current ||
|
||||||
|
transitionActive;
|
||||||
|
|
||||||
// Update camera yaw for mini-map rotation
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
needsRenderRef.current = false;
|
||||||
|
|
||||||
|
// Cập nhật yaw cho mini-map — chỉ setState khi đổi đủ lớn để tránh re-render thừa.
|
||||||
const direction = new THREE.Vector3();
|
const direction = new THREE.Vector3();
|
||||||
camera.getWorldDirection(direction);
|
camera.getWorldDirection(direction);
|
||||||
const yaw = yawFromDirection(direction);
|
const yaw = yawFromDirection(direction);
|
||||||
setCameraYaw(yaw);
|
if (Math.abs(normalizeYaw(yaw - lastYawRef.current)) > 0.15) {
|
||||||
|
lastYawRef.current = yaw;
|
||||||
|
setCameraYaw(yaw);
|
||||||
|
}
|
||||||
|
|
||||||
updateHotspots();
|
updateHotspots();
|
||||||
|
|
||||||
renderer.render(threeScene, camera);
|
renderer.render(threeScene, camera);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1496,6 +1557,7 @@ function TourExperience({
|
|||||||
renderer.domElement.removeEventListener("pointermove", handlePointerMove);
|
renderer.domElement.removeEventListener("pointermove", handlePointerMove);
|
||||||
renderer.domElement.removeEventListener("pointerup", handlePointerEnd);
|
renderer.domElement.removeEventListener("pointerup", handlePointerEnd);
|
||||||
renderer.domElement.removeEventListener("pointercancel", handlePointerEnd);
|
renderer.domElement.removeEventListener("pointercancel", handlePointerEnd);
|
||||||
|
controls.removeEventListener("change", handleControlsChange);
|
||||||
renderer.setAnimationLoop(null);
|
renderer.setAnimationLoop(null);
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
@@ -1703,6 +1765,15 @@ function TourExperience({
|
|||||||
|
|
||||||
setIsLoading(!initialCachedTexture);
|
setIsLoading(!initialCachedTexture);
|
||||||
setLoadProgress(initialCachedTexture ? 100 : 0);
|
setLoadProgress(initialCachedTexture ? 100 : 0);
|
||||||
|
if (!initialCachedTexture) {
|
||||||
|
const lqipTexture = loadLqipTexture(scene.image);
|
||||||
|
if (lqipTexture) {
|
||||||
|
material.map = lqipTexture;
|
||||||
|
material.opacity = 1;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
needsRenderRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
loadTexture
|
loadTexture
|
||||||
.then((texture) => {
|
.then((texture) => {
|
||||||
if (transitionTokenRef.current !== token) return;
|
if (transitionTokenRef.current !== token) return;
|
||||||
@@ -2941,14 +3012,30 @@ function MiniMap({
|
|||||||
const nodeSize = compact ? 5.2 : 5.2;
|
const nodeSize = compact ? 5.2 : 5.2;
|
||||||
const edgeInset = nodeSize / 2;
|
const edgeInset = nodeSize / 2;
|
||||||
const clampMapPoint = (value: number) => THREE.MathUtils.clamp(value, edgeInset, 100 - edgeInset);
|
const clampMapPoint = (value: number) => THREE.MathUtils.clamp(value, edgeInset, 100 - edgeInset);
|
||||||
const compressOverviewY = (value: number) => 50 + (value - 50) * 0.86;
|
|
||||||
|
// Auto-fit overview: co toàn bộ điểm vào khung với lề an toàn để không bị cắt.
|
||||||
|
const OVERVIEW_MARGIN = 8;
|
||||||
|
const overviewPts = scenes
|
||||||
|
.map((s) => s.mapPosition)
|
||||||
|
.filter((p): p is { x: number; y: number } => Boolean(p));
|
||||||
|
const overviewBounds = {
|
||||||
|
minX: Math.min(...overviewPts.map((p) => p.x)),
|
||||||
|
maxX: Math.max(...overviewPts.map((p) => p.x)),
|
||||||
|
minY: Math.min(...overviewPts.map((p) => p.y)),
|
||||||
|
maxY: Math.max(...overviewPts.map((p) => p.y)),
|
||||||
|
};
|
||||||
|
const fitOverview = (value: number, min: number, max: number) =>
|
||||||
|
max - min < 0.0001
|
||||||
|
? 50
|
||||||
|
: OVERVIEW_MARGIN + ((value - min) / (max - min)) * (100 - 2 * OVERVIEW_MARGIN);
|
||||||
|
|
||||||
const getMapPosition = (scene: TourScene) => {
|
const getMapPosition = (scene: TourScene) => {
|
||||||
if (!scene.mapPosition || (!isOverview && !activeScene.mapPosition)) return null;
|
if (!scene.mapPosition || (!isOverview && !activeScene.mapPosition)) return null;
|
||||||
|
|
||||||
if (isOverview) {
|
if (isOverview) {
|
||||||
return {
|
return {
|
||||||
x: clampMapPoint(scene.mapPosition.x),
|
x: clampMapPoint(fitOverview(scene.mapPosition.x, overviewBounds.minX, overviewBounds.maxX)),
|
||||||
y: clampMapPoint(compressOverviewY(scene.mapPosition.y)),
|
y: clampMapPoint(fitOverview(scene.mapPosition.y, overviewBounds.minY, overviewBounds.maxY)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.
|
||||||
|
// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.
|
||||||
|
export const lqip: Record<string, string> = {
|
||||||
|
"/images/chua-thai-lac/01.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABQECBP/EACgQAAIBAwEFCQAAAAAAAAAAAAECAwAEETEFEhMhUSIjMjNBYXGBof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAwDAQACEQMRAD8As77OlaO3Ktuq3mnByPjpz1oW7sRC5KhSh5Aqxx9eppwzk+HBIOoIq8SKUFJHQsMEHUA1mrIDtrWMwtLIyDA3gC35SFlLwpxFIUaBVzJ3ecg9PeutrZJY+0iFeoIraPHFGI0IyNd3mBTTH//Z",
|
||||||
|
"/images/chua-thai-lac/02.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAMEBf/EACMQAAIBBAICAgMAAAAAAAAAAAECAwAEERIFMSFxEyJhcoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAQEBAQAAAAAAAAAAAAAAAAARARL/2gAMAwEAAhEDEQA/ACLmgZUjkttWIBOCfFXScvaxWssjRnKEAAr3no+qmexQvtoVcjGw7p0FikcJSNXkLeTkFvVTrSM+Ll5EkDXADxk4KxqNgexVZ5SJrB50t3WQHVY27b8/ymixRSSYTn9KbJZJND8ckZKn7DKkYNLpH//Z",
|
||||||
|
"/images/chua-thai-lac/03.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEBf/EACQQAAIBAwMEAwEAAAAAAAAAAAECAwAREgQFITFBcYEUFSKR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/ALNHvME72YCMY5XzB9eaZrNyHw45dOyAEkMWHI9VP9dGyhWCoAMQc+170WhgIEaqMF4AtUukQR7xqYGDTAzRvexva9u4rUbcYAiOLkOuQ5/g80mPSwLb8Jx046U2TRQakiRSM4+AA1gaXcSP/9k=",
|
||||||
|
"/images/chua-thai-lac/04.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwQC/8QAIxAAAgIBBAICAwAAAAAAAAAAAQIDEQQABRIhEzEiQWGh4f/EABQBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEgH/2gAMAwEAAhEDEQA/ALMfdMOReXl8YuvkPf8ANOd0x5MRpIeLEGqNAnRT4uPNGAWUGqHXr8ami2yGI9OoX3Siu9FmWId2idh54nRT3YN6pizsd+QYrG6mirH96zJhYjKF4lSKAYN9aOLEg5uVd+JNkX0dF6Jf/9k=",
|
||||||
|
"/images/chua-thai-lac/05.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAQUAAAAAAAAAAAAAAAAABAECAwUG/8QAIxAAAgEDAwQDAAAAAAAAAAAAAQIDAAQREiExBUFRgRMUkf/EABYBAQEBAAAAAAAAAAAAAAAAAAEAAv/EABcRAQEBAQAAAAAAAAAAAAAAAAAREgH/2gAMAwEAAhEDEQA/ACrS/wCmC2iaQIuvtIMnx6qCf6l9FK9i6oyYJDbD1ReI5LcqHXjY4GKEtLK1hlDNcK5Bzpz3rOllQtJKFUtE++4OCM+aSMvqJIcHkbYGK0FykEkufmiYDhS3H5TVit8DOhGHOptyKL0x/9k=",
|
||||||
|
"/images/chua-thai-lac/06.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAgMBAAAAAAAAAAAAAAAAAgQBAwUG/8QAJRAAAgICAQMDBQAAAAAAAAAAAQIDBAARIRIxYQUyQRVRUnHB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgH/xAAaEQACAwEBAAAAAAAAAAAAAAAAAQIRIRJR/9oADAMBAAIRAxEAPwALiU7EDmvGI50UFtNpd/bWZcdGad0H5nR6T7R3JzoJqSKVavMIjrTcA78/vFVgsSWiXnCgjlwvOC34QGKD06tMERprBX50Cr+PGMPIjzKfp6JpSSAQRv44/mRYqtG3VWeP29Oie+VR17UzASOiKO+jhcp3iEua0//Z",
|
||||||
|
"/images/chua-thai-lac/07.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAAQCAwX/xAAiEAACAQMFAAMBAAAAAAAAAAABAgMABBEFEiEiMRVBgZH/xAAVAQEBAAAAAAAAAAAAAAAAAAABAP/EABkRAAMAAwAAAAAAAAAAAAAAAAABAgMREv/aAAwDAQACEQMRAD8A0k1C0jCl5lUEZ8zUItatrqSROsYTxiR2pJLCBWyIGBGOWer5rOGSMKm0bh2AFDsVOw+RtGcgyMozw2ODRPf20Eayb94bwLyf5S1tpiQSbgDkjHY+U3Nbq8AjTn6fadrfhoWQuD//2Q==",
|
||||||
|
"/images/chua-thai-lac/08.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAP/EACkQAAIBAgIIBwAAAAAAAAAAAAECEQADBAUSExQVIUFRgSJCUmFxkdH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAAMBAAAAAAAAAAAAAAAAAAABERL/2gAMAwEAAhEDEQA/AK97ZeonXp9U21nGAvhlR1keZkianuHDtytmOgFEPbA8JUdxU0xEK3lhbl0jiqesn55dqaMwwCaJN8AMJEg/lbWWQRxSesChtGHdtFnU+zRV0xEf/9k=",
|
||||||
|
"/images/chua-thai-lac/09.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAIEA//EACMQAAIBAwQBBQAAAAAAAAAAAAECAwAEEQUSITFBIiNRcpH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABYRAQEBAAAAAAAAAAAAAAAAAAASAf/aAAwDAQACEQMRAD8Ast9Xs5I42kdYnYZIY9GmutZs40Qh1cHsoOqMQjG1EHHgDihhE+NwDY+cGpWkornW7dFjaBGmLH1LnbtFa32qw2sCSQoZWboHIAGPNU+0vACfgpSIZOGSMj6ilaTj/9k=",
|
||||||
|
"/images/chua-thai-lac/10.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAABAUAAgP/xAAkEAACAQMCBwEBAAAAAAAAAAABAgMABBEFMRITFCEiYXFRgf/EABUBAQEAAAAAAAAAAAAAAAAAAAEC/8QAGBEBAAMBAAAAAAAAAAAAAAAAAAEDEhP/2gAMAwEAAhEDEQA/ADZdZigQPMrKpOAV74+1JNdtYngUlzzdyBng+0OUik8X4WX5WhitvHgjVSuxo2vnK+qazyYV6MxSSE4O/Yfvul1hrF686dQUaLOCcAH+UwuAksYBKE+xQyW0CHOIwRsSBRoxW//Z",
|
||||||
|
"/images/chua-thai-lac/11.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAgMEAQX/xAAlEAACAQMEAgEFAAAAAAAAAAABAgMABBEFEiExEyJRFEFSYYH/xAAUAQEAAAAAAAAAAAAAAAAAAAAB/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQAREv/aAAwDAQACEQMRAD8AZqurMsEa2E+yV+WOB6j4PwaVpTXyXm++u5XhRD2cgmr7i0tpguSW29ZPVAtonj2FjtDZ4JBo1nCK31eK58uyOUCMZ98DP6FQxa4wdpJAWRuBH91/tdAWlqQN4LY/NycVv0ViHZ1jUMeyOKOljC//2Q==",
|
||||||
|
"/images/chua-thai-lac/12.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAnEAACAQMCAwkAAAAAAAAAAAABAgMABBEFIRJBYRMVIiMyUXGRof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBR1LyTJGwaVV9IBwT1HtQu9dRJZnUADB8KjHxTruwSVENs4R1XG52YdaKum3DkJIvAnMqwNZbocLyamyxKjMgmfccAOB986LJrNwkhjQR5GNypxmn3FgknZlHEbKOHcZGKMbAs2HmjG+5C5/aOqJh//9k=",
|
||||||
|
"/images/chua-thai-lac/13.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAQIHAQEAAAAAAAAAAAECAxEABBIFEyEiMVFhQRT/xAAWAQEBAQAAAAAAAAAAAAAAAAABAAL/xAAZEQACAwEAAAAAAAAAAAAAAAAAEQECElH/2gAMAwEAAhEDEQA/ACPMDp5DzZGlDdjDr0+3izxCTU6YwgkBVo0m8k+/GJk4dC6s6m2K1TH995KHQvCq8rbuqmLfuZdhUMy0eKPUxW8oViQ5ksLXsfcvGsDyqg1M38yjt7e4+b6ivWN1XDRIqyGTc6eFrocJyWDBBHIisKZt10Pl4atwlB//2Q==",
|
||||||
|
"/images/chua-thai-lac/14.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABAMBBf/EACUQAAIBAwIFBQAAAAAAAAAAAAECAwAEERIhEyMxQVEUMmFxkf/EABYBAQEBAAAAAAAAAAAAAAAAAAIAA//EABkRAAMAAwAAAAAAAAAAAAAAAAABEQISIf/aAAwDAQACEQMRAD8APLbBghQM506mBPt+Kk8aqoYZHbIBI+669w1nJIhkSNVC6TliMDtRZIbJITH6hAhOVOsGs3gSSChRCnEWR8/gNagZ445Uzzd13Jz560mERSqyxyZC9CW26eKpaQyRnnSRsg3VUOMUdOQah//Z",
|
||||||
|
"/images/chua-thai-lac/15.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAgICAQUAAAAAAAAAAAECAxEABAUhEyISFTEyM3H/xAAVAQEBAAAAAAAAAAAAAAAAAAACAf/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBsfNxvqRFmBn6DqRXeR+rkyyEqBEnRA7N/3E7Wlo7SqG8SuoADWLFYccdpoWZpEotdAisO0XEA5Dcn3NgrpPJ4kA/Wasn74OCfcLP4JJy0Z9uz1m/LriR1l1thY6FfEGhlUfHWX8kigMfam/LC3WjlTnT/2Q==",
|
||||||
|
"/images/chua-thai-lac/16.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAv/EACEQAAICAgMAAgMAAAAAAAAAAAECAxEAEgQhMUFREyJx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAED/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQACEf/aAAwDAQACEQMRAD8AUvJvjKyMRfZ2HRxZeQwgvG6sR0D85Y8HGcBASF9Ftd/3A61CY3ZWQEVZFA5m5aoNiBDHCzu0RfWvxte1/YycpKpYvsGUbaeULyvjiKOczPIu2pXrw3jJV48nrsLFfq3x9YRYcL//2Q==",
|
||||||
|
"/images/chua-thai-lac/17.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABQMG/8QAJBAAAgIBAwIHAAAAAAAAAAAAAQIDEQAEEiExQQUTFCJRgaH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAAH/xAAaEQACAgMAAAAAAAAAAAAAAAAAAQMREyFB/9oADAMBAAIRAxEAPwBYy8Hb2NEnoMlLq3iVAIxIz8rR4/czom1BdTvZU6n3VzlfVvI4Ll5CtgBjYw7Y6iY2urlWPzZoNsYPJRgSPrKLroHra1k9B3wrUeJARiOMKhHcR2DhxMrQrU7ijzTE38ZW+GY3uz//2Q==",
|
||||||
|
"/images/chua-thai-lac/18.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAED/8QAIRAAAgEEAgIDAAAAAAAAAAAAAQIDAAQRIRITImEUQdH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAQL/xAAXEQEBAQEAAAAAAAAAAAAAAAAAEQEh/9oADAMBAAIRAxEAPwAHWwlCRCVig8goIJFLhmmiUE2UrKD5bDYrb45knuJo2ALgceRyCfyrbW9zJKnbxjjXZKnJPqjJ2iNIbu+IjgcoWIXOtD1Vmh6mKyRMFxrioNPmtl7i+wcnBFDWGPsZps5RsAknQ+qjU1//2Q==",
|
||||||
|
"/images/chua-thai-lac/19.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAgMEBf/EACUQAAIBAwQBBAMAAAAAAAAAAAECAwAEERIhMUEFExQiUSRhcf/EABYBAQEBAAAAAAAAAAAAAAAAAAIBA//EABcRAQEBAQAAAAAAAAAAAAAAAAEAAhH/2gAMAwEAAhEDEQA/AJ4vIe5DARMpHG3NaPj54HtzI7a3zsgxsPs0qO2iRhobSOxqzn978U6WC2lh0EIp41KBmga7a6EIze2pYqVxjsEEfzmsmTyP5cygFowcpgcjqr4LCCGQP6hkb6bBo7mCKRmLYBOPl3VdMcnb/9k=",
|
||||||
|
"/images/chua-thai-lac/20.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAQIFAAT/xAAkEAACAQMDBAMBAAAAAAAAAAABAgMABBEFEiETMUFxIlFhYv/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAGBEBAQADAAAAAAAAAAAAAAAAAQACEVH/2gAMAwEAAhEDEQA/AO6bVjbKDPDsUnAZW349jio0+oztemSJk6bN4zwPuqUmnQzbRliB/VONMtou0aj9zUVYHZ7u9PSjeBXXPLbozz6oLqkZChY3LngruwM+6ElvgIhmcIO6k5yKxtLRhkJ8gc5Hig5TRf/Z",
|
||||||
|
"/images/chua-thai-lac/21.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAID/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQRIRIxBUETFDKB/8QAFgEBAQEAAAAAAAAAAAAAAAAABAEC/8QAGBEAAwEBAAAAAAAAAAAAAAAAABESIRP/2gAMAwEAAhEDEQA/AMpLCyikdtMq6XA/Rotxaq7ZijVR0Me6uO2EkTqJ8nGgw0KTBbgyc/lRZSMZ6AFHt4J5rTCLx0Uh4SJwcAaHs1MfjLYuqknJbeegKZLEebs8o5dDjvIoU1pGwJ+w6nJJB7P9rTRJZ//Z",
|
||||||
|
"/images/chua-thai-lac/22.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAiEAACAQQCAgMBAAAAAAAAAAABAgMABBExEiETQWGBkaH/xAAWAQEBAQAAAAAAAAAAAAAAAAABAwT/xAAZEQEBAQADAAAAAAAAAAAAAAABAAIREzH/2gAMAwEAAhEDEQA/AIzXEnhwlvEGXZGDmi8JbkBiRGvyQKVPZwuV8cgQrvB3R5beXI4Trjvotkn+VI02hwHkV4WSZAUYq28mlZQkGGIDBxxY7+6qtlcOqiV+Sb67NVSxRAQWYD2Dv99UG0nq5v/Z",
|
||||||
|
"/images/chua-thai-lac/23.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAgME/8QAIxAAAgEEAAYDAAAAAAAAAAAAAQIDAAQRIQUSMTJBURMikf/EABcBAAMBAAAAAAAAAAAAAAAAAAABAwT/xAAZEQACAwEAAAAAAAAAAAAAAAAAAQIREkH/2gAMAwEAAhEDEQA/ACJE2wim5R0OD0pxzLLN8SqqNnucHQ85oSWTsw5CwX0TmtlpaKA4cdw81FUaHskXRQAzIHzjAqN2qxrHKJ8c3cM6G8ftaJOEAn6ysN7yM69VR7GKK1ESgtvJJO6MrolOdn//2Q==",
|
||||||
|
"/images/chua-thai-lac/24.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAMB/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQREiExE1EUInGx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAwAB/8QAGhEAAgMBAQAAAAAAAAAAAAAAAAECEkERIf/aAAwDAQACEQMRAD8AF5L1pmAJAxkBjilJLcQIkayCSY847B56o+gEqs10HUceuKS3xzNFNGRvEQwOeCaNSfPRHBYXW/vlXJjjEYOGYYOD6/alK90WEks4WJusD+it2thbNCFKh38hO+ftUDbW7RAJcmMnJYbd1tmVFp//2Q==",
|
||||||
|
"/images/chua-thai-lac/25.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwIE/8QAJBAAAgEEAQMFAQAAAAAAAAAAAQIDAAQRIRITMXEFFCJBQoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAA//EABkRAQADAQEAAAAAAAAAAAAAAAEAAhESQf/aAAwDAQACEQMRAD8AOWNRbxlRKJydqdCoje5t0EjcXJ3wJzgeaeaCzCgK7Hee/ajmtldgIkbiR+c/Gpjb2U5I7+pzOFX28agjOdHX8rDcRXKyO3UyoOQeWiPFKLAIRzeUj6BGKZ7dG6ZkYFgSWxRs5BU2f//Z",
|
||||||
|
"/images/chua-thai-lac/26.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAwQBAgX/xAAjEAACAgICAgEFAAAAAAAAAAABAgMRAAQSIRMUBSIjJDJh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAP/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQISEf/aAAwDAQACEQMRAD8AC/togWGZpZWPSEUBh4/kdyG1j4OFq2YgViiPohgW2mYHo2xwPlgUhX2PtH9lFWTdjJp0UzJpS7W3wb2x4/qKWpHRq8Qlj3omDNOHH8Y5abc053X8jnbciG6s1VnJl2dWRI4jOvJBTMGq8N1wZk//2Q==",
|
||||||
|
};
|
||||||