Files
phy-vr360/docs/superpowers/plans/2026-06-24-toi-uu-phy-vr360.md
tuyenio 97644677ad docs: plan triển khai tối ưu phy-vr360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:45:59 +07:00

762 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` ~2535MB; `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 12. ✓
- **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. ✓
```