"use client"; import { Info, BookOpenText, Layers3, LucideIcon, MapPin, Maximize2, Minimize2, Eye, EyeOff, Pause, Play, MoveRight, Scan, Settings, Volume2, VolumeX, X, Landmark, } from "lucide-react"; import Image from "next/image"; import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import Lottie, { type LottieRefCurrentProps } from "lottie-react"; import letterAnimation from "@/letter.json"; import { chuaThaiLacTour } from "@/app/tours/chua-thai-lac"; import { lqip } from "@/app/tours/chua-thai-lac.lqip"; type SceneId = string; type Hotspot = { targetId: SceneId; label: string; yaw: number; pitch: number; rotation?: number; nextYaw?: number; scale?: number; }; type InfoMarker = { id: string; title: string; eyebrow: string; yaw: number; pitch: number; rotation?: number; content: string[]; iconType?: "envelope" | "landmark" | "info"; }; type TourScene = { id: SceneId; order: string; title: string; location: string; image: string; initialYaw: number; initialPitch?: number; mapPosition: { x: number; y: number; }; hotspots: Hotspot[]; infoMarkers?: InfoMarker[]; }; type Panel = "scenes" | "info" | "map" | "settings" | null; type MapRoute = { from: SceneId; to: SceneId; via?: { x: number; y: number }[]; }; type WelcomeCardConfig = { image: string; label: string; rotate: string; sceneId: SceneId; }; type PracticalInfoItem = { label: string; value: string; }; type TourPracticalInfo = { intro: string; items: PracticalInfoItem[]; notes: string[]; }; type TourConfig = { id: string; title: string; subtitle: string; welcomeTitle: string; welcomeAccent: string; welcomeSubtitle: string; welcomeBackgroundImage: string; welcomeCards: WelcomeCardConfig[]; backgroundAudio: string; practicalInfo: TourPracticalInfo; scenes: TourScene[]; sceneById: Map; sceneNarration: Map; continuousNarrationGroups: SceneId[][]; mapRoutes: MapRoute[]; locationGroups: TourScene[]; }; export type { TourConfig, TourScene, Hotspot, InfoMarker, MapRoute, WelcomeCardConfig, TourPracticalInfo, PracticalInfoItem, }; const hotspotIcon = encodeURI("/icon/hotspotelement.png"); const infoModalBackground = encodeURI("/modal/modal-2.png"); const welcomeTitleBackground = encodeURI("/modal/background-1.png"); const PANORAMA_CAMERA_DISTANCE = 0.12; const DEFAULT_FOV = 76; const WIDE_FOV = 88; const MIN_ZOOM_FOV = 36; const MAX_ZOOM_FOV = 96; const AUDIO_TARGET_VOLUME = 0.16; const BACKGROUND_DUCK_VOLUME = 0.045; const NARRATION_TARGET_VOLUME = 0.7; const NARRATION_PLAYBACK_RATE = 1.48; const AUDIO_FADE_DURATION = 650; const clampFov = (fov: number) => THREE.MathUtils.clamp(fov, MIN_ZOOM_FOV, MAX_ZOOM_FOV); const normalizeYaw = (yaw: number) => { const normalized = ((yaw + 180) % 360) - 180; return normalized === -180 ? 180 : normalized; }; const isContinuousNarrationTransition = ( groups: SceneId[][], from: SceneId | null, to: SceneId, ) => { if (!from) { return false; } return groups.some((group) => group.includes(from) && group.includes(to)); }; function createTourConfig(config: Omit): TourConfig { const sceneById = config.scenes.reduce( (uniqueScenes, scene) => uniqueScenes.set(scene.id, scene), new Map(), ); const scenes = Array.from(sceneById.values()); const locationGroups = Array.from( scenes .reduce((groups, scene) => { if (!groups.has(scene.location)) { groups.set(scene.location, scene); } return groups; }, new Map()) .values(), ); return { ...config, scenes, sceneById, locationGroups, }; } const chuaThaiLacTourConfig = createTourConfig(chuaThaiLacTour); const toursById: Record = { "chua-thai-lac": chuaThaiLacTourConfig, }; const defaultTourConfig = chuaThaiLacTourConfig; 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(); 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(); const panoramaTexturePromises = new Map>(); const panoramaTextureProgress = new Map(); const panoramaTextureProgressListeners = new Map void>>(); const MAX_CACHED_PANORAMAS = 8; // đủ cho cảnh hiện tại + các cảnh lân cận const pinnedPanoramaUrls = new Set(); // Ghim các ảnh không được phép dispose (cảnh hiện tại + lân cận). function setPinnedPanoramas(urls: string[]) { pinnedPanoramaUrls.clear(); urls.forEach((url) => pinnedPanoramaUrls.add(url)); } // Đánh dấu ảnh là mới-dùng-nhất (Map giữ thứ tự chèn) rồi dọn nếu vượt giới hạn. function touchPanoramaCache(image: string, texture: THREE.Texture) { panoramaTextureCache.delete(image); panoramaTextureCache.set(image, texture); evictPanoramaTextures(); } function evictPanoramaTextures() { if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) { return; } for (const [url, texture] of panoramaTextureCache) { if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) { break; } if (pinnedPanoramaUrls.has(url)) { continue; // không dispose ảnh đang ghim (đang hiển thị/lân cận) } panoramaTextureCache.delete(url); texture.dispose(); } } function notifyPanoramaProgress(image: string, progress: number) { panoramaTextureProgress.set(image, progress); panoramaTextureProgressListeners.get(image)?.forEach((listener) => listener(progress)); } function subscribePanoramaProgress(image: string, listener?: (progress: number) => void) { if (!listener) { return () => undefined; } const listeners = panoramaTextureProgressListeners.get(image) ?? new Set<(progress: number) => void>(); listeners.add(listener); panoramaTextureProgressListeners.set(image, listeners); listener(panoramaTextureProgress.get(image) ?? 0); return () => { listeners.delete(listener); if (listeners.size === 0) { panoramaTextureProgressListeners.delete(image); } }; } function loadPanoramaTexture(image: string, onProgress?: (progress: number) => void) { const cachedTexture = panoramaTextureCache.get(image); if (cachedTexture) { touchPanoramaCache(image, cachedTexture); onProgress?.(100); return Promise.resolve(cachedTexture); } const unsubscribe = subscribePanoramaProgress(image, onProgress); const pendingTexture = panoramaTexturePromises.get(image); if (pendingTexture) { return pendingTexture.finally(unsubscribe); } const texturePromise = new Promise((resolve, reject) => { const loader = new THREE.TextureLoader(); notifyPanoramaProgress(image, 0); loader.load( image, (texture) => { texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; panoramaTextureCache.set(image, texture); evictPanoramaTextures(); panoramaTexturePromises.delete(image); notifyPanoramaProgress(image, 100); resolve(texture); }, (event) => { if (event.lengthComputable && event.total > 0) { notifyPanoramaProgress(image, Math.round((event.loaded / event.total) * 100)); } }, () => { panoramaTexturePromises.delete(image); panoramaTextureProgress.delete(image); reject(new Error("load-failed")); }, ); }); panoramaTexturePromises.set(image, texturePromise); return texturePromise.finally(unsubscribe); } function preloadSceneAndHotspots(scene: TourScene, sceneById: Map) { loadPanoramaTexture(scene.image).catch(() => undefined); scene.hotspots.forEach((hotspot) => { const targetScene = sceneById.get(hotspot.targetId); if (targetScene) { loadPanoramaTexture(targetScene.image).catch(() => undefined); } }); } function yawFromDirection(direction: THREE.Vector3) { return normalizeYaw(THREE.MathUtils.radToDeg(Math.atan2(direction.x, -direction.z))); } 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, ); } 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({ tourId = "chua-thai-lac", }: { tourId?: string; }) { const tourConfig = getTourConfig(tourId); const [hasEntered, setHasEntered] = useState(false); const [isEntering, setIsEntering] = useState(false); const [initialSceneId, setInitialSceneId] = useState("scene-1"); const [soundEnabled, setSoundEnabled] = useState(true); const enterTimerRef = useRef(null); const audioRef = useRef(null); const audioFadeRef = useRef(null); const fadeAudioTo = useCallback( (targetVolume: number, duration = AUDIO_FADE_DURATION, pauseAfter = false) => { const audio = audioRef.current; if (!audio) { return; } const setSafeVolume = (value: number) => { const safeValue = THREE.MathUtils.clamp(Number.isFinite(value) ? value : 0, 0, 1); audio.volume = safeValue; return safeValue; }; if (audioFadeRef.current) { cancelAnimationFrame(audioFadeRef.current); } const clampedTarget = THREE.MathUtils.clamp(targetVolume, 0, 1); const startVolume = THREE.MathUtils.clamp( Number.isFinite(audio.volume) ? audio.volume : 0, 0, 1, ); const delta = clampedTarget - startVolume; if (duration <= 0 || Math.abs(delta) < 0.01) { setSafeVolume(clampedTarget); if (pauseAfter && clampedTarget === 0) { audio.pause(); } return; } const startTime = performance.now(); const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); const tick = (now: number) => { const progress = Math.min((now - startTime) / duration, 1); setSafeVolume(startVolume + delta * easeOutCubic(progress)); if (progress < 1) { audioFadeRef.current = requestAnimationFrame(tick); return; } audioFadeRef.current = null; if (pauseAfter && clampedTarget === 0) { audio.pause(); } }; audioFadeRef.current = requestAnimationFrame(tick); }, [], ); const playAudio = useCallback(async () => { const audio = audioRef.current; if (!audio) { return; } if (audio.paused) { audio.volume = 0; try { await audio.play(); } catch (error) { console.error("Audio autoplay blocked:", error); return; } } fadeAudioTo(AUDIO_TARGET_VOLUME, AUDIO_FADE_DURATION); }, [fadeAudioTo]); const pauseAudio = useCallback(() => { fadeAudioTo(0, AUDIO_FADE_DURATION, true); }, [fadeAudioTo]); const toggleSound = useCallback(() => { setSoundEnabled((value) => !value); }, []); const handleEnter = useCallback((sceneId: SceneId = "scene-1") => { if (isEntering) { return; } setInitialSceneId(sceneId); setIsEntering(true); if (soundEnabled) { void playAudio(); } if (enterTimerRef.current) { window.clearTimeout(enterTimerRef.current); } enterTimerRef.current = window.setTimeout(() => { setHasEntered(true); }, 720); }, [isEntering, playAudio, soundEnabled]); useEffect(() => { const audio = audioRef.current; if (audio) { audio.volume = 0; } return () => { if (audioFadeRef.current) { cancelAnimationFrame(audioFadeRef.current); } }; }, []); const handleNarrationStateChange = useCallback( (isPlaying: boolean) => { if (!soundEnabled) { return; } fadeAudioTo(isPlaying ? BACKGROUND_DUCK_VOLUME : AUDIO_TARGET_VOLUME, AUDIO_FADE_DURATION); }, [fadeAudioTo, soundEnabled], ); useEffect(() => { if (!hasEntered) { return; } if (soundEnabled) { void playAudio(); } else { pauseAudio(); } }, [hasEntered, pauseAudio, playAudio, soundEnabled]); useEffect(() => { return () => { if (enterTimerRef.current) { window.clearTimeout(enterTimerRef.current); } }; }, []); return ( <> {tourConfig.backgroundAudio ? (