diff --git a/public/images/Đình Thái Lạc/4.jpg b/public/images/Chùa Thái Lạc/4.jpg similarity index 100% rename from public/images/Đình Thái Lạc/4.jpg rename to public/images/Chùa Thái Lạc/4.jpg diff --git a/public/images/Đình Thái Lạc/Ao Cá Koi.jpg b/public/images/Chùa Thái Lạc/Ao Cá Koi.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Ao Cá Koi.jpg rename to public/images/Chùa Thái Lạc/Ao Cá Koi.jpg diff --git a/public/images/Đình Thái Lạc/Chính Diện Cổng Chùa.jpg b/public/images/Chùa Thái Lạc/Chính Diện Cổng Chùa.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chính Diện Cổng Chùa.jpg rename to public/images/Chùa Thái Lạc/Chính Diện Cổng Chùa.jpg diff --git a/public/images/Đình Thái Lạc/Chính Sảnh.jpg b/public/images/Chùa Thái Lạc/Chính Sảnh.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chính Sảnh.jpg rename to public/images/Chùa Thái Lạc/Chính Sảnh.jpg diff --git a/public/images/Đình Thái Lạc/Chính Điện 3.jpg b/public/images/Chùa Thái Lạc/Chính Điện 3.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chính Điện 3.jpg rename to public/images/Chùa Thái Lạc/Chính Điện 3.jpg diff --git a/public/images/Đình Thái Lạc/Chính.jpg b/public/images/Chùa Thái Lạc/Chính.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chính.jpg rename to public/images/Chùa Thái Lạc/Chính.jpg diff --git a/public/images/Đình Thái Lạc/Chùa 1 Giữa.jpg b/public/images/Chùa Thái Lạc/Chùa 1 Giữa.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chùa 1 Giữa.jpg rename to public/images/Chùa Thái Lạc/Chùa 1 Giữa.jpg diff --git a/public/images/Đình Thái Lạc/Chùa 1 Hữu.jpg b/public/images/Chùa Thái Lạc/Chùa 1 Hữu.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chùa 1 Hữu.jpg rename to public/images/Chùa Thái Lạc/Chùa 1 Hữu.jpg diff --git a/public/images/Đình Thái Lạc/Chùa 1 Tả.jpg b/public/images/Chùa Thái Lạc/Chùa 1 Tả.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chùa 1 Tả.jpg rename to public/images/Chùa Thái Lạc/Chùa 1 Tả.jpg diff --git a/public/images/Đình Thái Lạc/Chùa 1 Trong - Giữa.jpg b/public/images/Chùa Thái Lạc/Chùa 1 Trong - Giữa.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chùa 1 Trong - Giữa.jpg rename to public/images/Chùa Thái Lạc/Chùa 1 Trong - Giữa.jpg diff --git a/public/images/Đình Thái Lạc/Chùa 1 Trong Tả.jpg b/public/images/Chùa Thái Lạc/Chùa 1 Trong Tả.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Chùa 1 Trong Tả.jpg rename to public/images/Chùa Thái Lạc/Chùa 1 Trong Tả.jpg diff --git a/public/images/Đình Thái Lạc/Cổng Bên Trái.jpg b/public/images/Chùa Thái Lạc/Cổng Bên Trái.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Cổng Bên Trái.jpg rename to public/images/Chùa Thái Lạc/Cổng Bên Trái.jpg diff --git a/public/images/Đình Thái Lạc/Cổng Hữu.jpg b/public/images/Chùa Thái Lạc/Cổng Hữu.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Cổng Hữu.jpg rename to public/images/Chùa Thái Lạc/Cổng Hữu.jpg diff --git a/public/images/Đình Thái Lạc/Duong Tả Vòm.jpg b/public/images/Chùa Thái Lạc/Duong Tả Vòm.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Duong Tả Vòm.jpg rename to public/images/Chùa Thái Lạc/Duong Tả Vòm.jpg diff --git a/public/images/Đình Thái Lạc/Giuawx.jpg b/public/images/Chùa Thái Lạc/Giuawx.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Giuawx.jpg rename to public/images/Chùa Thái Lạc/Giuawx.jpg diff --git a/public/images/Đình Thái Lạc/Gốc Ngoã Hữu.jpg b/public/images/Chùa Thái Lạc/Gốc Ngoã Hữu.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Gốc Ngoã Hữu.jpg rename to public/images/Chùa Thái Lạc/Gốc Ngoã Hữu.jpg diff --git a/public/images/Đình Thái Lạc/Gốc Ngoã Tả.jpg b/public/images/Chùa Thái Lạc/Gốc Ngoã Tả.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Gốc Ngoã Tả.jpg rename to public/images/Chùa Thái Lạc/Gốc Ngoã Tả.jpg diff --git a/public/images/Đình Thái Lạc/Hữu 2.jpg b/public/images/Chùa Thái Lạc/Hữu 2.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Hữu 2.jpg rename to public/images/Chùa Thái Lạc/Hữu 2.jpg diff --git a/public/images/Đình Thái Lạc/Hữu 3.jpg b/public/images/Chùa Thái Lạc/Hữu 3.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Hữu 3.jpg rename to public/images/Chùa Thái Lạc/Hữu 3.jpg diff --git a/public/images/Đình Thái Lạc/La Hán Tả.jpg b/public/images/Chùa Thái Lạc/La Hán Tả.jpg similarity index 100% rename from public/images/Đình Thái Lạc/La Hán Tả.jpg rename to public/images/Chùa Thái Lạc/La Hán Tả.jpg diff --git a/public/images/Đình Thái Lạc/Lối Vào Been Trái.jpg b/public/images/Chùa Thái Lạc/Lối Vào Been Trái.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Lối Vào Been Trái.jpg rename to public/images/Chùa Thái Lạc/Lối Vào Been Trái.jpg diff --git a/public/images/Đình Thái Lạc/Nhà Phật Hữu.jpg b/public/images/Chùa Thái Lạc/Nhà Phật Hữu.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Nhà Phật Hữu.jpg rename to public/images/Chùa Thái Lạc/Nhà Phật Hữu.jpg diff --git a/public/images/Đình Thái Lạc/Sau Chùa.jpg b/public/images/Chùa Thái Lạc/Sau Chùa.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Sau Chùa.jpg rename to public/images/Chùa Thái Lạc/Sau Chùa.jpg diff --git a/public/images/Đình Thái Lạc/Tượng Quan Âm.jpg b/public/images/Chùa Thái Lạc/Tượng Quan Âm.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Tượng Quan Âm.jpg rename to public/images/Chùa Thái Lạc/Tượng Quan Âm.jpg diff --git a/public/images/Đình Thái Lạc/Đường Hữu.jpg b/public/images/Chùa Thái Lạc/Đường Hữu.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Đường Hữu.jpg rename to public/images/Chùa Thái Lạc/Đường Hữu.jpg diff --git a/public/images/Đình Thái Lạc/Đường Tả Qua Vòm.jpg b/public/images/Chùa Thái Lạc/Đường Tả Qua Vòm.jpg similarity index 100% rename from public/images/Đình Thái Lạc/Đường Tả Qua Vòm.jpg rename to public/images/Chùa Thái Lạc/Đường Tả Qua Vòm.jpg diff --git a/src/app/components/VirtualTour.tsx b/src/app/components/VirtualTour.tsx new file mode 100644 index 0000000..3fb57a7 --- /dev/null +++ b/src/app/components/VirtualTour.tsx @@ -0,0 +1,3215 @@ +"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"; + +type SceneId = string; + +type Hotspot = { + targetId: SceneId; + label: string; + yaw: number; + pitch: number; + rotation?: number; + nextYaw?: 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; + 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; + +const panoramaTextureCache = new Map(); +const panoramaTexturePromises = new Map>(); +const panoramaTextureProgress = new Map(); +const panoramaTextureProgressListeners = new Map void>>(); + +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) { + 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); + 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, + ); +} + +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 ? ( +