diff --git a/docs/superpowers/plans/2026-06-24-toi-uu-phy-vr360.md b/docs/superpowers/plans/2026-06-24-toi-uu-phy-vr360.md new file mode 100644 index 0000000..4fa1785 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-toi-uu-phy-vr360.md @@ -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) ` + +--- + +## 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 = {\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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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(); +``` + +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(); +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) " +``` + +--- + +## 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) " +``` + +--- + +## 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. ✓ +```