Files
phy-vr360/src/app/components/VirtualTour.tsx
T
2026-06-24 16:07:17 +07:00

3261 lines
120 KiB
TypeScript

"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;
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<SceneId, TourScene>;
sceneNarration: Map<SceneId, string[]>;
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, "sceneById" | "locationGroups">): TourConfig {
const sceneById = config.scenes.reduce(
(uniqueScenes, scene) => uniqueScenes.set(scene.id, scene),
new Map<SceneId, TourScene>(),
);
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<string, TourScene>())
.values(),
);
return {
...config,
scenes,
sceneById,
locationGroups,
};
}
const chuaThaiLacTourConfig = createTourConfig(chuaThaiLacTour);
const toursById: Record<string, TourConfig> = {
"chua-thai-lac": chuaThaiLacTourConfig,
};
const defaultTourConfig = chuaThaiLacTourConfig;
const getTourConfig = (tourId?: string) => toursById[tourId ?? ""] ?? defaultTourConfig;
const panoramaTextureCache = new Map<string, THREE.Texture>();
const panoramaTexturePromises = new Map<string, Promise<THREE.Texture>>();
const panoramaTextureProgress = new Map<string, number>();
const panoramaTextureProgressListeners = new Map<string, Set<(progress: number) => 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<THREE.Texture>((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<SceneId, TourScene>) {
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<SceneId>("scene-1");
const [soundEnabled, setSoundEnabled] = useState(true);
const enterTimerRef = useRef<number | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioFadeRef = useRef<number | null>(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 ? (
<audio ref={audioRef} src={tourConfig.backgroundAudio} preload="auto" loop playsInline />
) : null}
{!hasEntered ? (
<WelcomeScreen tourConfig={tourConfig} isEntering={isEntering} onEnter={handleEnter} />
) : (
<TourExperience
tourConfig={tourConfig}
initialSceneId={initialSceneId}
soundEnabled={soundEnabled}
onToggleSound={toggleSound}
onNarrationStateChange={handleNarrationStateChange}
/>
)}
</>
);
}
function useDeviceOrientation() {
const [orientation, setOrientation] = useState<{ alpha: number; beta: number; gamma: number } | null>(null);
const [isSupported] = useState(
() => typeof window !== "undefined" && "DeviceOrientationEvent" in window,
);
const requestPermission = useCallback(async () => {
const orientationEvent = DeviceOrientationEvent as typeof DeviceOrientationEvent & {
requestPermission?: () => Promise<PermissionState>;
};
if (typeof orientationEvent.requestPermission === "function") {
try {
const permission = await orientationEvent.requestPermission();
if (permission === 'granted') {
return true;
}
} catch (error) {
console.error('Permission denied:', error);
return false;
}
}
return true;
}, []);
const startListening = useCallback(() => {
const handleOrientation = (event: DeviceOrientationEvent) => {
if (event.alpha !== null && event.beta !== null && event.gamma !== null) {
setOrientation({
alpha: event.alpha,
beta: event.beta,
gamma: event.gamma,
});
}
};
window.addEventListener("deviceorientation", handleOrientation, true);
return () => window.removeEventListener("deviceorientation", handleOrientation, true);
}, []);
return { orientation, isSupported, requestPermission, startListening };
}
function WelcomeScreen({
tourConfig,
isEntering,
onEnter,
}: {
tourConfig: TourConfig;
isEntering: boolean;
onEnter: (sceneId?: SceneId) => void;
}) {
const [isPanoramaReady, setIsPanoramaReady] = useState(false);
const [isFirstSceneReady, setIsFirstSceneReady] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animationRef = useRef<number | null>(null);
const canEnterTour = isPanoramaReady && isFirstSceneReady;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.setSize(window.innerWidth, window.innerHeight);
const camera = new THREE.PerspectiveCamera(
76,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.set(0, 0, 0.1);
const scene = new THREE.Scene();
const geometry = new THREE.SphereGeometry(500, 128, 72);
geometry.scale(-1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
const sphere = new THREE.Mesh(geometry, material);
scene.add(sphere);
let isDisposed = false;
const firstScene = tourConfig.sceneById.get("scene-1") ?? tourConfig.scenes[0];
preloadSceneAndHotspots(firstScene, tourConfig.sceneById);
loadPanoramaTexture(firstScene.image)
.then(() => {
if (!isDisposed) {
setIsFirstSceneReady(true);
}
})
.catch(() => {
if (!isDisposed) {
setIsFirstSceneReady(true);
}
});
loadPanoramaTexture(tourConfig.welcomeBackgroundImage)
.then((texture) => {
if (isDisposed) {
return;
}
material.map = texture;
material.needsUpdate = true;
setIsPanoramaReady(true);
})
.catch(() => {
if (!isDisposed) {
setIsPanoramaReady(true);
}
});
let rotation = 0;
const animate = () => {
rotation += 0.0003;
camera.rotation.y = rotation;
renderer.render(scene, camera);
animationRef.current = requestAnimationFrame(animate);
};
animate();
const handleResize = () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
isDisposed = true;
window.removeEventListener("resize", handleResize);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
geometry.dispose();
material.dispose();
renderer.dispose();
};
}, [tourConfig]);
return (
<main
className={`tour-welcome fixed inset-0 min-h-[100dvh] overflow-hidden bg-[var(--background)] text-[var(--foreground)] transition-[opacity,transform,filter,scale] duration-500 ease-[cubic-bezier(.2,.8,.2,1)] ${isEntering ? "pointer-events-none scale-[1.02] opacity-0 blur-[2px]" : "opacity-100"
}`}
>
<canvas
ref={canvasRef}
className={`absolute inset-0 h-full w-full sepia-[0.24] contrast-[1.24] saturate-[0.92] brightness-[0.68] transition-opacity duration-500 ${isPanoramaReady ? "opacity-100" : "opacity-0"
}`}
/>
<div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(16,25,19,0.42),rgba(36,28,19,0.08)_48%,rgba(12,18,14,0.48))]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,232,180,0.08)_0%,rgba(65,48,31,0.08)_45%,rgba(5,8,6,0.38)_100%)]" />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,226,176,0.1)_0%,rgba(40,31,22,0.02)_38%,rgba(10,10,8,0.32)_100%)]" />
<div className="pointer-events-none absolute inset-0 opacity-[0.06] [background-image:linear-gradient(0deg,rgba(255,252,245,0.12)_1px,transparent_1px)] [background-size:100%_4px]" />
<div
className={`absolute inset-0 z-20 grid place-items-center bg-[var(--background)] text-[var(--tour-ink)] transition-opacity duration-500 ${canEnterTour ? "pointer-events-none opacity-0" : "opacity-100"
}`}
>
<div className="rounded-full border border-[rgb(166_124_82_/_0.22)] bg-[rgb(255_252_245_/_0.72)] px-5 py-2 text-[0.82rem] font-black uppercase tracking-[0.18em] shadow-[0_18px_52px_rgb(74_63_53_/_0.16)]">
Đang tải không gian 360°...
</div>
</div>
<section
className={`relative z-10 grid min-h-[100dvh] place-items-center px-4 py-8 transition-[opacity,transform] duration-500 ${canEnterTour ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
}`}
>
<div className="flex w-full max-w-5xl flex-col items-center text-center">
<div className="relative w-[min(88vw,780px)] px-4 pt-9 sm:px-9 sm:pb-7 sm:pt-9 lg:w-[min(90vw,840px)]">
<Image
src={welcomeTitleBackground}
alt=""
fill
loading="eager"
sizes="1000px"
className="pointer-events-none scale-x-[1.32] scale-y-[1.72] select-none object-contain sm:scale-x-[1.05] sm:scale-y-[1.35] lg:scale-x-[1.16] lg:scale-y-[1.26]"
draggable={false}
/>
<h1 className="font-display-vn relative text-balance text-[1.85rem] font-bold uppercase leading-[1.18] text-[var(--tour-ink)] drop-shadow-[0_2px_0_rgba(255,252,245,0.92),0_3px_8px_rgba(45,38,33,0.28)] sm:text-[3rem] sm:leading-[1.12] lg:text-[3.72rem]">
{tourConfig.welcomeTitle}
<span className="mt-1.5 block text-[var(--primary)] sm:mt-1">{tourConfig.welcomeAccent}</span>
</h1>
<p className="relative mx-auto mt-3.5 max-w-[58ch] text-balance text-[0.78rem] font-bold leading-5 text-[rgb(58_50_44_/_0.9)] drop-shadow-[0_1px_0_rgba(255,252,245,0.76)] sm:mt-4 sm:text-[0.95rem] sm:leading-6">
{tourConfig.welcomeSubtitle}
</p>
</div>
<button
type="button"
onClick={() => onEnter("scene-1")}
disabled={isEntering || !canEnterTour}
className="group relative mt-5 inline-flex min-w-[15.5rem] items-center justify-between gap-4 overflow-hidden rounded-full border border-[rgb(232_207_170_/_0.72)] bg-[linear-gradient(90deg,#173f2d_0%,#315f50_52%,#b98b56_100%)] px-5 py-2.5 text-[0.95rem] font-extrabold text-white shadow-[0_16px_48px_rgb(8_10_7_/_0.44),0_0_0_3px_rgb(185_139_86_/_0.22),inset_0_1px_0_rgb(255_255_255_/_0.26),inset_0_-1px_0_rgb(45_38_33_/_0.3)] transition-[transform,box-shadow,filter] duration-300 ease-out hover:-translate-y-0.5 hover:shadow-[0_22px_62px_rgb(8_10_7_/_0.52),0_0_0_3px_rgb(232_207_170_/_0.28),inset_0_1px_0_rgb(255_255_255_/_0.34)] active:translate-y-0 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:mt-6 sm:min-w-[17rem] sm:px-6 sm:py-3 sm:text-[1.04rem]"
>
<span className="welcome-cta-sheen pointer-events-none absolute inset-0" />
<span className="relative">Bắt đu tham quan</span>
<span className="relative grid h-9 w-9 shrink-0 place-items-center rounded-full border border-[rgb(255_252_245_/_0.56)] bg-[linear-gradient(135deg,#8e6533,#c89a55)] text-white shadow-[0_6px_14px_rgb(45_38_33_/_0.28),inset_0_1px_0_rgb(255_255_255_/_0.34)] transition duration-300 group-hover:translate-x-0.5 group-hover:brightness-110">
<MoveRight className="h-4 w-4" strokeWidth={2.4} />
</span>
</button>
<div className="relative mt-6 grid w-full max-w-[480px] grid-cols-2 gap-4 px-4 sm:mt-7 sm:gap-6">
{tourConfig.welcomeCards.map((card) => (
<WelcomeCard
key={card.sceneId}
image={card.image}
label={card.label}
rotate={card.rotate}
disabled={isEntering || !canEnterTour}
onClick={() => onEnter(card.sceneId)}
/>
))}
</div>
</div>
</section>
<style jsx global>{`
@keyframes welcomeCtaSheen {
from {
transform: translateX(-120%) skewX(-18deg);
}
to {
transform: translateX(180%) skewX(-18deg);
}
}
.welcome-cta-sheen {
background: linear-gradient(90deg, transparent, rgba(255, 252, 245, 0.28), transparent);
animation: welcomeCtaSheen 3.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.welcome-title-plaque {
isolation: isolate;
border: 2px solid rgba(185, 139, 86, 0.62);
border-radius: 56px 56px 48px 48px / 42px 42px 36px 36px;
background:
radial-gradient(circle at 50% 0%, rgba(255, 252, 245, 0.94), transparent 24%),
linear-gradient(180deg, rgba(255, 252, 245, 0.96), rgba(246, 234, 210, 0.96));
box-shadow:
0 28px 74px rgba(8, 10, 7, 0.38),
0 0 0 5px rgba(255, 252, 245, 0.62),
0 0 0 7px rgba(166, 124, 82, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -14px 28px rgba(166, 124, 82, 0.12);
}
.welcome-title-plaque::before,
.welcome-title-plaque::after {
content: "";
position: absolute;
top: 30%;
z-index: -1;
width: 5.25rem;
height: 6.1rem;
border: 2px solid rgba(185, 139, 86, 0.55);
background: linear-gradient(180deg, rgba(255, 252, 245, 0.96), rgba(246, 234, 210, 0.96));
box-shadow:
0 0 0 5px rgba(255, 252, 245, 0.62),
0 0 0 7px rgba(166, 124, 82, 0.34),
0 22px 54px rgba(8, 10, 7, 0.28);
}
.welcome-title-plaque::before {
left: -2.6rem;
border-radius: 999px 0 0 999px;
border-right: 0;
}
.welcome-title-plaque::after {
right: -2.6rem;
border-left: 0;
border-radius: 0 999px 999px 0;
}
.welcome-plaque-crown {
position: absolute;
left: 50%;
top: -3.05rem;
z-index: -1;
width: 9.5rem;
height: 5.8rem;
transform: translateX(-50%);
border: 2px solid rgba(185, 139, 86, 0.58);
border-bottom: 0;
border-radius: 999px 999px 0 0;
background:
radial-gradient(circle at 50% 52%, rgba(185, 139, 86, 0.28) 0 0.34rem, transparent 0.36rem),
linear-gradient(180deg, rgba(255, 252, 245, 0.96), rgba(246, 234, 210, 0.96));
box-shadow:
0 0 0 5px rgba(255, 252, 245, 0.62),
0 0 0 7px rgba(166, 124, 82, 0.34);
}
.welcome-plaque-crown::before,
.welcome-plaque-crown::after {
content: "";
position: absolute;
left: 50%;
background: rgba(185, 139, 86, 0.72);
transform: translateX(-50%) rotate(45deg);
}
.welcome-plaque-crown::before {
top: 1.45rem;
width: 1.15rem;
height: 1.15rem;
border-radius: 0.16rem;
}
.welcome-plaque-crown::after {
top: 2.72rem;
width: 4.4rem;
height: 1px;
border-radius: 999px;
transform: translateX(-50%);
}
.welcome-plaque-ornament {
position: absolute;
top: 50%;
width: 5.8rem;
height: 2.8rem;
transform: translateY(-8%);
opacity: 0.34;
background:
radial-gradient(ellipse at 24% 54%, transparent 0 0.65rem, rgba(185, 139, 86, 0.76) 0.68rem 0.76rem, transparent 0.8rem),
radial-gradient(ellipse at 44% 54%, transparent 0 0.44rem, rgba(185, 139, 86, 0.62) 0.47rem 0.54rem, transparent 0.58rem),
linear-gradient(90deg, transparent 0%, rgba(185, 139, 86, 0.74) 52%, transparent 100%);
background-size: 100% 100%, 100% 100%, 100% 1px;
background-position: center, center, center;
background-repeat: no-repeat;
}
.welcome-plaque-ornament--left {
left: 2.4rem;
}
.welcome-plaque-ornament--right {
right: 2.4rem;
transform: translateY(-8%) scaleX(-1);
}
@media (prefers-reduced-motion: reduce) {
.welcome-cta-sheen,
.tour-envelope-glow {
animation: none;
}
.welcome-cta-sheen {
opacity: 0;
}
.tour-envelope-glow {
opacity: 0.32;
}
}
@media (max-width: 640px) {
.welcome-title-plaque::before,
.welcome-title-plaque::after,
.welcome-plaque-ornament {
display: none;
}
.welcome-plaque-crown {
top: -2.4rem;
width: 7rem;
height: 4.4rem;
}
}
`}</style>
</main>
);
}
function WelcomeCard({
image,
label,
rotate,
disabled,
onClick,
}: {
image: string;
label: string;
rotate: string;
disabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`group relative block w-full text-left focus:outline-none disabled:cursor-not-allowed disabled:opacity-70 ${rotate}`}
title={label}
aria-label={label}
>
<div className="absolute inset-0 rounded-[8px] bg-[conic-gradient(from_140deg,#f6ead4,#c8a27a,#315f50,#c8a27a,#f6ead4)] opacity-55 blur-[14px]" />
<div className="relative rounded-[8px] bg-[linear-gradient(150deg,rgba(255,252,245,0.9),rgba(255,252,245,0.55)_46%,rgba(255,252,245,0.2))] p-[2px] shadow-[0_26px_70px_rgb(74_63_53_/_0.26)]">
<div className="rounded-[8px] border border-[rgb(166_124_82_/_0.18)] bg-[linear-gradient(160deg,rgba(255,252,245,0.85),rgba(236,229,216,0.6))] p-2">
<div className="relative aspect-[4/3] overflow-hidden rounded-[7px]">
<Image
src={image}
alt={label}
fill
sizes="260px"
className="object-cover transition duration-500 group-hover:scale-[1.05]"
loading="eager"
/>
<span className="absolute inset-0 bg-[linear-gradient(120deg,rgba(255,255,255,0.3),transparent_40%,rgba(0,0,0,0.2))]" />
<span className="absolute inset-0 ring-1 ring-white/40" />
</div>
<div className="mt-2 flex items-center justify-between px-1">
<p className="font-display-vn max-w-[10.6rem] truncate text-[0.8rem] font-bold text-[var(--foreground)] sm:max-w-[11.8rem] sm:text-[0.9rem]" title={label}>
{label}
</p>
</div>
</div>
</div>
</button>
);
}
function TourExperience({
tourConfig,
initialSceneId,
soundEnabled,
onToggleSound,
onNarrationStateChange,
}: {
tourConfig: TourConfig;
initialSceneId: SceneId;
soundEnabled: boolean;
onToggleSound: () => void;
onNarrationStateChange?: (isPlaying: boolean) => void;
}) {
const [currentSceneId, setCurrentSceneId] = useState<SceneId>(initialSceneId);
const [activePanel, setActivePanel] = useState<Panel>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadProgress, setLoadProgress] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const [autoRotate, setAutoRotate] = useState(false);
const [wideAngle, setWideAngle] = useState(false);
const [vrMode, setVrMode] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMiniMapExpanded, setIsMiniMapExpanded] = useState(true);
const [isMiniMapOverviewOpen, setIsMiniMapOverviewOpen] = useState(false);
const [cameraYaw, setCameraYaw] = useState(0);
const [showItems, setShowItems] = useState(true);
const rootRef = useRef<HTMLElement | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const mountRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const materialRef = useRef<THREE.MeshBasicMaterial | null>(null);
const transitionMaterialRef = useRef<THREE.MeshBasicMaterial | null>(null);
const warmedTextureUrlsRef = useRef<Set<string>>(new Set());
const hotspotRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const currentSceneRef = useRef<TourScene>(tourConfig.sceneById.get(initialSceneId) ?? tourConfig.scenes[0]);
const lastOrientationRef = useRef<{ alpha: number; beta: number; gamma: number } | null>(null);
const stopOrientationRef = useRef<(() => void) | null>(null);
const vrModeRef = useRef(false);
const vrDragOffsetRef = useRef({ yaw: 0, pitch: 0 });
const vrYawOffsetRef = useRef<number | null>(null);
const vrPointerRef = useRef<{ id: number; x: number; y: number } | null>(null);
const transitionFrameRef = useRef<number | null>(null);
const transitionTokenRef = useRef(0);
const initialSceneRef = useRef(true);
const transitionLockRef = useRef(false);
const currentFovRef = useRef(DEFAULT_FOV);
const targetFovRef = useRef(DEFAULT_FOV);
const needsRenderRef = useRef(true);
const lastYawRef = useRef(0);
const lastPinchDistanceRef = useRef<number | null>(null);
const infoMarkerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const [activeInfoMarker, setActiveInfoMarker] = useState<InfoMarker | null>(null);
const [isInfoMarkerClosing, setIsInfoMarkerClosing] = useState(false);
const [openingInfoMarkerId, setOpeningInfoMarkerId] = useState<string | null>(null);
const [readMarkers, setReadMarkers] = useState<Set<string>>(new Set());
const [hoveredMarkerId, setHoveredMarkerId] = useState<string | null>(null);
const openingInfoTimerRef = useRef<number | null>(null);
const closingInfoTimerRef = useRef<number | null>(null);
const narrationRef = useRef<HTMLAudioElement | null>(null);
const narrationQueueRef = useRef<string[]>([]);
const narrationTokenRef = useRef(0);
const activeNarrationSrcRef = useRef<string | null>(null);
const lastNarrationSceneRef = useRef<SceneId | null>(null);
const { orientation, isSupported, requestPermission, startListening } = useDeviceOrientation();
const stopNarration = useCallback(() => {
const audio = narrationRef.current;
narrationTokenRef.current += 1;
narrationQueueRef.current = [];
activeNarrationSrcRef.current = null;
if (audio) {
audio.pause();
audio.currentTime = 0;
audio.onended = null;
}
onNarrationStateChange?.(false);
}, [onNarrationStateChange]);
const playNarrationSequence = useCallback(
async (sources: string[]) => {
const audio = narrationRef.current;
if (!audio || sources.length === 0) {
onNarrationStateChange?.(false);
return;
}
narrationTokenRef.current += 1;
const token = narrationTokenRef.current;
narrationQueueRef.current = [...sources];
const playNext = async () => {
if (token !== narrationTokenRef.current) {
return;
}
const nextSource = narrationQueueRef.current.shift();
if (!nextSource) {
activeNarrationSrcRef.current = null;
onNarrationStateChange?.(false);
return;
}
if (audio.src !== nextSource) {
audio.src = nextSource;
}
audio.currentTime = 0;
audio.playbackRate = NARRATION_PLAYBACK_RATE;
audio.volume = NARRATION_TARGET_VOLUME;
activeNarrationSrcRef.current = nextSource;
try {
await audio.play();
onNarrationStateChange?.(true);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.error("Narration autoplay blocked:", error);
onNarrationStateChange?.(false);
}
};
audio.onended = () => {
void playNext();
};
void playNext();
},
[onNarrationStateChange],
);
useEffect(() => {
vrModeRef.current = vrMode;
}, [vrMode]);
useEffect(() => {
if (!soundEnabled) {
stopNarration();
lastNarrationSceneRef.current = null;
return;
}
const narrationSources = tourConfig.sceneNarration.get(currentSceneId);
if (!narrationSources || narrationSources.length === 0) {
stopNarration();
lastNarrationSceneRef.current = currentSceneId;
return;
}
const audio = narrationRef.current;
const shouldKeepPlaying = isContinuousNarrationTransition(
tourConfig.continuousNarrationGroups,
lastNarrationSceneRef.current,
currentSceneId,
);
if (
audio &&
shouldKeepPlaying &&
activeNarrationSrcRef.current === narrationSources[0] &&
!audio.paused
) {
lastNarrationSceneRef.current = currentSceneId;
return;
}
void playNarrationSequence(narrationSources);
lastNarrationSceneRef.current = currentSceneId;
}, [currentSceneId, playNarrationSequence, soundEnabled, stopNarration, tourConfig]);
const activeScene = useMemo(
() => tourConfig.sceneById.get(currentSceneId) ?? tourConfig.scenes[0],
[currentSceneId, tourConfig],
);
const closeInfoMarker = useCallback(() => {
if (!activeInfoMarker || isInfoMarkerClosing) {
return;
}
if (closingInfoTimerRef.current) {
window.clearTimeout(closingInfoTimerRef.current);
}
setIsInfoMarkerClosing(true);
closingInfoTimerRef.current = window.setTimeout(() => {
setActiveInfoMarker(null);
setIsInfoMarkerClosing(false);
closingInfoTimerRef.current = null;
}, 240);
}, [activeInfoMarker, isInfoMarkerClosing]);
useEffect(() => {
if (!activeInfoMarker) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeInfoMarker();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [activeInfoMarker, closeInfoMarker]);
useEffect(() => {
const narrationAudio = narrationRef.current;
return () => {
if (openingInfoTimerRef.current) {
window.clearTimeout(openingInfoTimerRef.current);
}
if (closingInfoTimerRef.current) {
window.clearTimeout(closingInfoTimerRef.current);
}
if (narrationAudio) {
narrationAudio.onended = null;
}
};
}, []);
const positionCamera = useCallback((scene: TourScene, distance = PANORAMA_CAMERA_DISTANCE, yaw = scene.initialYaw) => {
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) {
return;
}
// Góc nhìn dọc mặc định của cảnh (ngẩng/cúi khi vừa vào cảnh); mặc định 0 = ngang tầm mắt.
const pitch = scene.initialPitch ?? 0;
const direction = directionFromYawPitch(yaw, pitch).normalize();
camera.position.copy(direction.multiplyScalar(-distance));
controls.target.set(0, 0, 0);
controls.update();
}, []);
const getTouchDistance = useCallback((touches: TouchList) => {
if (touches.length < 2) {
return null;
}
const first = touches.item(0);
const second = touches.item(1);
if (!first || !second) {
return null;
}
return Math.hypot(first.clientX - second.clientX, first.clientY - second.clientY);
}, []);
const updateHotspots = useCallback(() => {
const mount = mountRef.current;
const camera = cameraRef.current;
const scene = currentSceneRef.current;
if (!mount || !camera) {
return;
}
const { clientWidth, clientHeight } = mount;
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
scene.hotspots.forEach((hotspot) => {
const element = hotspotRefs.current.get(`${scene.id}-${hotspot.targetId}`);
if (!element) {
return;
}
const worldPosition = directionFromYawPitch(hotspot.yaw, hotspot.pitch)
.normalize()
.multiplyScalar(240);
const isVisible = worldPosition.clone().normalize().dot(cameraDirection) > 0.08;
const projected = worldPosition.clone().project(camera);
const x = (projected.x * 0.5 + 0.5) * clientWidth;
const y = (-projected.y * 0.5 + 0.5) * clientHeight;
const isOnScreen =
isVisible &&
projected.z < 1 &&
x > -80 &&
x < clientWidth + 80 &&
y > -80 &&
y < clientHeight + 80;
// `scale` (xa/gần) đặt trong dữ liệu cảnh: >1 = mũi tên to/gần hơn, <1 = nhỏ/xa hơn.
const arrowScale = (hotspot.scale ?? 1) * (isOnScreen ? 1 : 0.88);
element.style.opacity = isOnScreen ? "1" : "0";
element.style.pointerEvents = isOnScreen ? "auto" : "none";
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.transform = `translate(-50%, -50%) scale(${arrowScale})`;
});
scene.infoMarkers?.forEach((marker) => {
const element = infoMarkerRefs.current.get(`${scene.id}-${marker.id}`);
if (!element) {
return;
}
const worldPosition = directionFromYawPitch(marker.yaw, marker.pitch)
.normalize()
.multiplyScalar(240);
const isVisible = worldPosition.clone().normalize().dot(cameraDirection) > 0.08;
const projected = worldPosition.clone().project(camera);
const x = (projected.x * 0.5 + 0.5) * clientWidth;
const y = (-projected.y * 0.5 + 0.5) * clientHeight;
const isOnScreen =
isVisible &&
projected.z < 1 &&
x > -80 &&
x < clientWidth + 80 &&
y > -80 &&
y < clientHeight + 80;
element.style.opacity = isOnScreen ? "1" : "0";
element.style.pointerEvents = isOnScreen ? "auto" : "none";
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.transform = `translate(-50%, -50%) scale(${isOnScreen ? 1 : 0.88})`;
});
}, []);
const setTargetFov = useCallback(
(nextFov: number, immediate = false) => {
const camera = cameraRef.current;
const fov = clampFov(nextFov);
targetFovRef.current = fov;
if (immediate && camera) {
currentFovRef.current = fov;
camera.fov = fov;
camera.updateProjectionMatrix();
updateHotspots();
}
},
[updateHotspots],
);
const warmPanoramaTexture = useCallback((image: string, texture: THREE.Texture) => {
const renderer = rendererRef.current;
if (!renderer || warmedTextureUrlsRef.current.has(image)) {
return;
}
renderer.initTexture(texture);
warmedTextureUrlsRef.current.add(image);
}, []);
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],
);
useEffect(() => {
const mount = mountRef.current;
if (!mount) {
return;
}
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.setSize(mount.clientWidth, mount.clientHeight);
mount.appendChild(renderer.domElement);
rendererRef.current = renderer;
const camera = new THREE.PerspectiveCamera(
currentFovRef.current,
mount.clientWidth / mount.clientHeight,
0.1,
1000,
);
camera.position.set(0, 0, PANORAMA_CAMERA_DISTANCE);
const threeScene = new THREE.Scene();
const geometry = new THREE.SphereGeometry(500, 128, 72);
geometry.scale(-1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 1 });
const transitionMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0,
});
const sphere = new THREE.Mesh(geometry, material);
const transitionSphere = new THREE.Mesh(geometry, transitionMaterial);
threeScene.add(sphere);
threeScene.add(transitionSphere);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.09;
controls.enablePan = false;
controls.enableZoom = false;
controls.rotateSpeed = -0.42;
controls.minPolarAngle = 0.03;
controls.maxPolarAngle = Math.PI - 0.03;
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.ROTATE,
};
const handleWheel = (event: WheelEvent) => {
if (vrModeRef.current || transitionLockRef.current) {
return;
}
event.preventDefault();
const deltaModeScale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? 320 : 1;
const normalizedDelta = THREE.MathUtils.clamp(event.deltaY * deltaModeScale, -240, 240);
const sensitivity = event.ctrlKey ? 0.028 : 0.038;
setTargetFov(targetFovRef.current + normalizedDelta * sensitivity);
};
const handleTouchStart = (event: TouchEvent) => {
lastPinchDistanceRef.current = getTouchDistance(event.touches);
};
const handleTouchMove = (event: TouchEvent) => {
if (vrModeRef.current || transitionLockRef.current || event.touches.length !== 2) {
return;
}
const distance = getTouchDistance(event.touches);
const previousDistance = lastPinchDistanceRef.current;
if (!distance || !previousDistance) {
lastPinchDistanceRef.current = distance;
return;
}
event.preventDefault();
const ratio = THREE.MathUtils.clamp(previousDistance / distance, 0.82, 1.18);
setTargetFov(targetFovRef.current * ratio);
lastPinchDistanceRef.current = distance;
};
const handleTouchEnd = (event: TouchEvent) => {
lastPinchDistanceRef.current = getTouchDistance(event.touches);
};
const handlePointerDown = (event: PointerEvent) => {
if (!vrModeRef.current || transitionLockRef.current || event.pointerType === "mouse") {
return;
}
event.preventDefault();
vrPointerRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
renderer.domElement.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent) => {
const pointer = vrPointerRef.current;
if (!vrModeRef.current || !pointer || pointer.id !== event.pointerId) {
return;
}
event.preventDefault();
const deltaX = event.clientX - pointer.x;
const deltaY = event.clientY - pointer.y;
vrDragOffsetRef.current.yaw += deltaX * 0.18;
vrDragOffsetRef.current.pitch = THREE.MathUtils.clamp(
vrDragOffsetRef.current.pitch + deltaY * 0.12,
-55,
55,
);
vrPointerRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
};
const handlePointerEnd = (event: PointerEvent) => {
const pointer = vrPointerRef.current;
if (!pointer || pointer.id !== event.pointerId) {
return;
}
vrPointerRef.current = null;
if (renderer.domElement.hasPointerCapture(event.pointerId)) {
renderer.domElement.releasePointerCapture(event.pointerId);
}
};
const handleControlsChange = () => {
needsRenderRef.current = true;
};
controls.addEventListener("change", handleControlsChange);
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
renderer.domElement.addEventListener("touchstart", handleTouchStart, { passive: false });
renderer.domElement.addEventListener("touchmove", handleTouchMove, { passive: false });
renderer.domElement.addEventListener("touchend", handleTouchEnd, { passive: false });
renderer.domElement.addEventListener("touchcancel", handleTouchEnd, { passive: false });
renderer.domElement.addEventListener("pointerdown", handlePointerDown, { passive: false });
renderer.domElement.addEventListener("pointermove", handlePointerMove, { passive: false });
renderer.domElement.addEventListener("pointerup", handlePointerEnd);
renderer.domElement.addEventListener("pointercancel", handlePointerEnd);
cameraRef.current = camera;
controlsRef.current = controls;
materialRef.current = material;
transitionMaterialRef.current = transitionMaterial;
positionCamera(currentSceneRef.current);
const resize = () => {
const width = mount.clientWidth;
const height = mount.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
updateHotspots();
};
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);
};
renderer.setAnimationLoop(animate);
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
renderer.domElement.removeEventListener("wheel", handleWheel);
renderer.domElement.removeEventListener("touchstart", handleTouchStart);
renderer.domElement.removeEventListener("touchmove", handleTouchMove);
renderer.domElement.removeEventListener("touchend", handleTouchEnd);
renderer.domElement.removeEventListener("touchcancel", handleTouchEnd);
renderer.domElement.removeEventListener("pointerdown", handlePointerDown);
renderer.domElement.removeEventListener("pointermove", handlePointerMove);
renderer.domElement.removeEventListener("pointerup", handlePointerEnd);
renderer.domElement.removeEventListener("pointercancel", handlePointerEnd);
controls.removeEventListener("change", handleControlsChange);
renderer.setAnimationLoop(null);
controls.dispose();
geometry.dispose();
material.dispose();
transitionMaterial.dispose();
renderer.dispose();
renderer.domElement.remove();
rendererRef.current = null;
};
}, [getTouchDistance, positionCamera, setTargetFov, updateHotspots]);
useEffect(() => {
const controls = controlsRef.current;
if (controls) {
controls.autoRotate = autoRotate && !vrMode;
controls.autoRotateSpeed = 0.85;
}
}, [autoRotate, vrMode]);
useEffect(() => {
setTargetFov(wideAngle ? WIDE_FOV : DEFAULT_FOV);
}, [setTargetFov, wideAngle]);
const setTransitionVisuals = useCallback((progress: number) => {
const wrap = canvasWrapRef.current;
if (!wrap) {
return;
}
const intensity = Math.sin(Math.PI * progress);
wrap.style.setProperty("--tour-blur", `${(4 * intensity).toFixed(2)}px`);
wrap.style.setProperty("--tour-scale", `${(1 + intensity * 0.01).toFixed(3)}`);
wrap.style.setProperty("--tour-vignette", `${(0.2 * intensity).toFixed(3)}`);
}, []);
const runSceneTransition = useCallback(
(scene: TourScene, arrivalYaw = scene.initialYaw) => {
currentSceneRef.current = scene;
const camera = cameraRef.current;
const controls = controlsRef.current;
const material = materialRef.current;
const transitionMaterial = transitionMaterialRef.current;
if (!camera || !controls || !material || !transitionMaterial) {
positionCamera(scene);
return;
}
if (transitionFrameRef.current) {
cancelAnimationFrame(transitionFrameRef.current);
transitionFrameRef.current = null;
}
const token = ++transitionTokenRef.current;
const isVrTransition = vrModeRef.current;
setLoadError(null);
transitionLockRef.current = !initialSceneRef.current;
preloadTourScene(scene);
const cachedTexture = panoramaTextureCache.get(scene.image);
if (!initialSceneRef.current) {
const startPosition = camera.position.clone();
const startRotation = camera.rotation.clone();
const targetYaw = arrivalYaw;
const targetDirection = directionFromYawPitch(targetYaw, 0).normalize();
const targetPosition = targetDirection.multiplyScalar(-PANORAMA_CAMERA_DISTANCE);
const duration = 1000;
const startTime = performance.now();
const easeInOutQuart = (t: number) =>
t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;
let textureLoaded = !!cachedTexture;
let loadedTexture: THREE.Texture | null = cachedTexture || null;
let textureFailed = false;
let textureReadyAt = cachedTexture ? startTime : 0;
setIsTransitioning(true);
setIsLoading(!cachedTexture);
setLoadProgress(cachedTexture ? 100 : 0);
setTransitionVisuals(0);
const prepareTransitionTexture = (texture: THREE.Texture) => {
warmPanoramaTexture(scene.image, texture);
transitionMaterial.map = texture;
transitionMaterial.opacity = 0;
transitionMaterial.needsUpdate = true;
};
if (cachedTexture) {
prepareTransitionTexture(cachedTexture);
} else {
loadPanoramaTexture(scene.image, (progress) => {
if (transitionTokenRef.current === token) {
setLoadProgress(progress);
}
})
.then((texture) => {
if (transitionTokenRef.current !== token) return;
textureLoaded = true;
loadedTexture = texture;
textureReadyAt = performance.now();
setIsLoading(false);
setLoadProgress(100);
prepareTransitionTexture(texture);
})
.catch(() => {
if (transitionTokenRef.current !== token) return;
textureFailed = true;
setLoadError("Không tải được ảnh panorama cho cảnh này.");
setIsLoading(false);
});
}
const tick = (now: number) => {
if (transitionTokenRef.current !== token) {
return;
}
const rawProgress = (now - startTime) / duration;
const progress = !textureLoaded && !textureFailed ? Math.min(rawProgress, 0.72) : Math.min(rawProgress, 1);
const eased = easeInOutQuart(progress);
let fadeProgress = 0;
if (!isVrTransition) {
camera.position.lerpVectors(startPosition, targetPosition, eased);
camera.rotation.x = THREE.MathUtils.lerp(startRotation.x, 0, eased);
camera.rotation.y = THREE.MathUtils.lerp(startRotation.y, THREE.MathUtils.degToRad(targetYaw), eased);
camera.rotation.z = THREE.MathUtils.lerp(startRotation.z, 0, eased);
camera.position.setLength(PANORAMA_CAMERA_DISTANCE);
controls.update();
}
// Fade when texture is loaded
if (textureLoaded && loadedTexture) {
const fadeStart = 0.2;
const fadeEnd = 0.9;
fadeProgress =
textureReadyAt > startTime + duration * fadeEnd
? Math.min(Math.max((now - textureReadyAt) / 420, 0), 1)
: Math.min(Math.max((progress - fadeStart) / (fadeEnd - fadeStart), 0), 1);
const fadeEase = easeInOutQuart(fadeProgress);
transitionMaterial.opacity = fadeEase;
material.opacity = 1 - fadeEase;
}
setTransitionVisuals(progress);
if (progress < 1 || (textureLoaded && loadedTexture && fadeProgress < 1)) {
transitionFrameRef.current = requestAnimationFrame(tick);
return;
}
if (!textureLoaded && !textureFailed) {
setIsLoading(true);
setTransitionVisuals(0.5);
transitionFrameRef.current = requestAnimationFrame(tick);
return;
}
if (textureLoaded && loadedTexture) {
material.map = loadedTexture;
material.opacity = 1;
material.needsUpdate = true;
transitionMaterial.opacity = 0;
transitionMaterial.needsUpdate = true;
} else {
material.opacity = 1;
transitionMaterial.opacity = 0;
transitionMaterial.needsUpdate = true;
}
transitionFrameRef.current = null;
if (!isVrTransition) {
positionCamera(scene, PANORAMA_CAMERA_DISTANCE, targetYaw);
}
setTransitionVisuals(0);
setIsTransitioning(false);
setIsLoading(false);
setLoadProgress(100);
transitionLockRef.current = false;
updateHotspots();
preloadTourScene(scene);
};
transitionFrameRef.current = requestAnimationFrame(tick);
return;
}
// Initial scene load - use global cache
const initialCachedTexture = panoramaTextureCache.get(scene.image);
const loadTexture = initialCachedTexture
? Promise.resolve(initialCachedTexture)
: loadPanoramaTexture(scene.image, (progress) => {
if (transitionTokenRef.current === token) {
setLoadProgress(progress);
}
});
setIsLoading(!initialCachedTexture);
setLoadProgress(initialCachedTexture ? 100 : 0);
loadTexture
.then((texture) => {
if (transitionTokenRef.current !== token) return;
warmPanoramaTexture(scene.image, texture);
material.map = texture;
material.opacity = 1;
material.needsUpdate = true;
transitionMaterial.map = texture;
transitionMaterial.opacity = 0;
transitionMaterial.needsUpdate = true;
positionCamera(scene);
updateHotspots();
initialSceneRef.current = false;
setIsTransitioning(false);
setIsLoading(false);
setLoadProgress(100);
setTransitionVisuals(0);
transitionLockRef.current = false;
preloadTourScene(scene);
})
.catch(() => {
if (transitionTokenRef.current !== token) return;
setIsLoading(false);
setLoadError("Không tải được ảnh panorama cho cảnh này.");
transitionLockRef.current = false;
});
},
[positionCamera, preloadTourScene, setTransitionVisuals, updateHotspots, warmPanoramaTexture],
);
useEffect(() => {
// Only run auto-transition when scene changes from scene list
if (!transitionLockRef.current) {
runSceneTransition(activeScene);
}
}, [activeScene, runSceneTransition]);
useEffect(() => {
return () => {
if (transitionFrameRef.current) {
cancelAnimationFrame(transitionFrameRef.current);
}
stopOrientationRef.current?.();
};
}, []);
// VR Mode - Device Orientation
useEffect(() => {
if (!vrMode || !orientation) return;
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) return;
const alpha = THREE.MathUtils.degToRad(orientation.alpha);
const beta = THREE.MathUtils.degToRad(orientation.beta);
const gamma = THREE.MathUtils.degToRad(orientation.gamma);
const screenOrientation = THREE.MathUtils.degToRad(window.screen.orientation?.angle ?? window.orientation ?? 0);
const zee = new THREE.Vector3(0, 0, 1);
const euler = new THREE.Euler(beta, alpha, -gamma, "YXZ");
const headsetCorrection = new THREE.Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5));
const screenCorrection = new THREE.Quaternion().setFromAxisAngle(zee, -screenOrientation);
const dragOffset = vrDragOffsetRef.current;
const orientationQuaternion = new THREE.Quaternion()
.setFromEuler(euler)
.multiply(headsetCorrection)
.multiply(screenCorrection);
const orientationDirection = new THREE.Vector3(0, 0, -1).applyQuaternion(orientationQuaternion);
const orientationYaw = yawFromDirection(orientationDirection);
if (vrYawOffsetRef.current === null) {
const currentDirection = new THREE.Vector3();
camera.getWorldDirection(currentDirection);
vrYawOffsetRef.current = normalizeYaw(yawFromDirection(currentDirection) - orientationYaw);
}
const yawOffset = vrYawOffsetRef.current ?? 0;
const yawCorrection = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
THREE.MathUtils.degToRad(yawOffset),
);
const dragCorrection = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
THREE.MathUtils.degToRad(dragOffset.pitch),
THREE.MathUtils.degToRad(dragOffset.yaw),
0,
"YXZ",
),
);
camera.quaternion.copy(orientationQuaternion);
camera.quaternion.premultiply(yawCorrection);
camera.quaternion.premultiply(dragCorrection);
lastOrientationRef.current = orientation;
}, [orientation, vrMode]);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const handleFullscreen = () => {
setIsFullscreen(Boolean(document.fullscreenElement));
};
handleFullscreen();
document.addEventListener("fullscreenchange", handleFullscreen);
return () => document.removeEventListener("fullscreenchange", handleFullscreen);
}, []);
const toggleVrMode = useCallback(async () => {
if (!vrMode) {
const permitted = await requestPermission();
if (permitted) {
stopOrientationRef.current?.();
stopOrientationRef.current = startListening();
setVrMode(true);
setAutoRotate(false);
vrDragOffsetRef.current = { yaw: 0, pitch: 0 };
vrYawOffsetRef.current = null;
vrPointerRef.current = null;
lastOrientationRef.current = null;
if (controlsRef.current) {
controlsRef.current.autoRotate = false;
controlsRef.current.enabled = false;
}
}
} else {
setVrMode(false);
stopOrientationRef.current?.();
stopOrientationRef.current = null;
vrPointerRef.current = null;
vrYawOffsetRef.current = null;
lastOrientationRef.current = null;
if (controlsRef.current) {
controlsRef.current.enabled = true;
controlsRef.current.update();
}
}
}, [vrMode, requestPermission, startListening]);
const toggleFullscreen = useCallback(async () => {
if (typeof document === "undefined") {
return;
}
try {
if (!document.fullscreenElement) {
await rootRef.current?.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (error) {
console.error("Fullscreen error:", error);
}
}, []);
const goToScene = (sceneId: SceneId, keepPanel = false, arrivalYaw?: number) => {
if (sceneId === currentSceneId || transitionLockRef.current) {
return;
}
setLoadError(null);
setCurrentSceneId(sceneId);
setActiveInfoMarker(null);
setIsInfoMarkerClosing(false);
if (!keepPanel) {
setActivePanel(null);
}
const targetScene = tourConfig.sceneById.get(sceneId);
if (targetScene) {
runSceneTransition(targetScene, arrivalYaw ?? targetScene.initialYaw);
}
};
return (
<main
ref={rootRef}
className="fixed inset-0 min-h-[100dvh] overflow-hidden bg-[var(--tour-wood)] text-white"
>
<audio ref={narrationRef} preload="auto" playsInline />
<div ref={canvasWrapRef} className="tour-canvas absolute inset-0">
<div ref={mountRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
</div>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(45,38,33,0.1)_0%,rgba(45,38,33,0.02)_42%,rgba(45,38,33,0.68)_100%)]" />
<div
className={`fixed left-3 top-3 z-20 hidden transition-[width,opacity,transform] duration-300 sm:left-4 sm:top-4 sm:block ${isMiniMapExpanded ? "w-[min(52vw,220px)] sm:w-[240px]" : "w-[min(72vw,260px)] sm:w-auto"
}`}
>
{isMiniMapExpanded ? (
<div className="relative">
<div className="absolute right-2 top-2 z-30 flex gap-1.5">
<button
type="button"
onClick={() => setIsMiniMapOverviewOpen(true)}
className="grid h-8 w-8 place-items-center rounded-[5px] border border-[rgb(232_207_170_/_0.28)] bg-[rgb(45_38_33_/_0.72)] text-white/90 shadow-[0_8px_22px_rgba(0,0,0,0.22)] backdrop-blur-xl transition hover:border-[var(--tour-gold-light)] hover:bg-[rgb(45_38_33_/_0.9)] active:scale-95"
aria-label="Phóng to bản đồ"
title="Toàn bản đồ"
>
<Maximize2 className="h-3.5 w-3.5" strokeWidth={2.4} />
</button>
<button
type="button"
onClick={() => setIsMiniMapExpanded(false)}
className="h-8 rounded-[5px] border border-white/14 bg-[rgb(45_38_33_/_0.72)] px-2 text-[0.62rem] font-bold text-white/86 shadow-[0_8px_22px_rgba(0,0,0,0.22)] backdrop-blur-xl transition hover:bg-[rgb(45_38_33_/_0.86)] active:scale-95"
aria-label="Thu gọn mini-map"
title="Thu gọn mini-map"
>
Thu gọn
</button>
</div>
<MiniMap
tourConfig={tourConfig}
activeScene={activeScene}
onSceneSelect={(sceneId) => goToScene(sceneId, true)}
compact
cameraYaw={cameraYaw}
mode="overview"
/>
</div>
) : (
<button
type="button"
onClick={() => setIsMiniMapExpanded(true)}
className="flex max-w-full items-center gap-2 rounded-[6px] border border-[rgb(255_252_245_/_0.18)] bg-[linear-gradient(135deg,rgb(255_252_245_/_0.14),transparent_44%),rgb(45_38_33_/_0.66)] px-2.5 py-2 text-left text-white shadow-[0_18px_54px_rgba(0,0,0,0.3),inset_0_1px_0_rgb(255_255_255_/_0.14)] backdrop-blur-xl transition hover:bg-[rgb(45_38_33_/_0.78)] active:scale-95"
aria-label="Mở mini-map"
title="Mở mini-map"
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#e83030] text-[0.65rem] font-black text-white shadow-[0_0_0_4px_rgb(232_48_48_/_0.18)]">
{activeScene.order}
</span>
<span className="min-w-0">
<span className="block text-[0.62rem] font-black uppercase tracking-[0.12em] text-[var(--tour-gold-light)]">
Mở map
</span>
<span className="block truncate text-[0.76rem] font-bold text-white">{activeScene.title}</span>
</span>
</button>
)}
</div>
{isMiniMapOverviewOpen ? (
<div className="fixed inset-0 z-40 grid place-items-center bg-[rgb(22_18_15_/_0.54)] px-3 py-4 backdrop-blur-[4px] sm:px-5 sm:py-6">
<button
type="button"
className="absolute inset-0 cursor-default"
onClick={() => setIsMiniMapOverviewOpen(false)}
aria-label="Đóng bản đồ phóng to"
/>
<section className="relative max-h-[88dvh] w-[min(92vw,760px)] overflow-y-auto rounded-[8px] border border-[rgb(232_207_170_/_0.2)] bg-[linear-gradient(135deg,rgb(255_252_245_/_0.13),rgb(255_252_245_/_0.045)_40%,transparent_72%),rgb(45_38_33_/_0.78)] p-3 text-white shadow-[0_38px_120px_rgb(0_0_0_/_0.48),inset_0_1px_0_rgb(255_255_255_/_0.16)] backdrop-blur-2xl [scrollbar-color:var(--tour-gold)_transparent] [scrollbar-width:thin] sm:p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[0.7rem] font-black uppercase tracking-[0.16em] text-[var(--tour-gold-light)]">
Toàn bộ tuyến tham quan
</p>
<h2 className="mt-1 text-xl font-black tracking-[-0.03em] text-white sm:text-2xl">
Bản đ điểm đến
</h2>
</div>
<button
type="button"
onClick={() => setIsMiniMapOverviewOpen(false)}
className="grid h-9 w-9 shrink-0 place-items-center rounded-[8px] border border-white/12 bg-white/[0.06] text-white transition hover:bg-white/[0.12] active:scale-95"
aria-label="Đóng bản đồ phóng to"
title="Đóng"
>
<X className="h-4 w-4" strokeWidth={2.4} />
</button>
</div>
<MiniMap
tourConfig={tourConfig}
activeScene={activeScene}
onSceneSelect={(sceneId) => {
goToScene(sceneId, true);
setIsMiniMapOverviewOpen(false);
}}
cameraYaw={cameraYaw}
mode="overview"
mapClassName="aspect-[16/10]"
showLegend
compact
/>
</section>
</div>
) : null}
{isLoading ? (
<div className="fixed left-1/2 top-4 z-20 w-[min(84vw,280px)] -translate-x-1/2 rounded-[7px] border border-white/16 bg-[rgb(45_38_33_/_0.68)] px-3 py-2 text-white shadow-[0_12px_34px_rgba(0,0,0,0.28)] backdrop-blur-xl">
<div className="flex items-center justify-between gap-3 text-[0.68rem] font-black uppercase tracking-[0.12em] text-white/88">
<span>Tải không gian 360</span>
<span className="font-mono text-[0.74rem] text-[var(--tour-gold-light)]">
{Math.max(0, Math.min(100, Math.round(loadProgress)))}%
</span>
</div>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-white/14">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#e8cfaa,#ffffff)] transition-[width] duration-200"
style={{ width: `${Math.max(4, Math.min(100, Math.round(loadProgress)))}%` }}
/>
</div>
</div>
) : null}
<div
className={`pointer-events-none absolute inset-0 z-10 transition-opacity duration-200 ${isTransitioning ? "opacity-0" : "opacity-100"
}`}
>
{activeScene.hotspots.map((hotspot) => (
<button
key={`${activeScene.id}-${hotspot.targetId}`}
ref={(node) => {
const key = `${activeScene.id}-${hotspot.targetId}`;
if (node) {
hotspotRefs.current.set(key, node);
} else {
hotspotRefs.current.delete(key);
}
}}
type="button"
className="group absolute left-1/2 top-1/2 grid h-[3.2rem] w-[3.2rem] -translate-x-1/2 -translate-y-1/2 place-items-center opacity-0 drop-shadow-[0_10px_18px_rgba(0,0,0,0.46)] transition-[opacity,transform,filter,scale] duration-500 hover:scale-105 hover:drop-shadow-[0_14px_24px_rgba(0,0,0,0.58)] active:scale-96 sm:h-16 sm:w-16"
style={
{
"--hotspot-angle": `${hotspot.rotation ?? 0}deg`,
} as CSSProperties
}
onClick={() => goToScene(hotspot.targetId, false, hotspot.nextYaw ?? hotspot.yaw)}
aria-label={hotspot.label}
title={hotspot.label}
>
<Image
src={hotspotIcon}
alt=""
width={128}
height={128}
loading="eager"
unoptimized
className="h-full w-full object-contain [transform:rotate(var(--hotspot-angle))]"
draggable={false}
/>
<span className="pointer-events-none absolute left-1/2 top-[calc(100%+0.35rem)] max-w-[9.5rem] -translate-x-1/2 whitespace-nowrap rounded-[6px] border border-white/16 bg-[rgb(45_38_33_/_0.72)] px-2.5 py-1 text-[0.72rem] font-bold text-white opacity-0 shadow-[0_12px_28px_rgba(0,0,0,0.34)] backdrop-blur-xl transition group-hover:opacity-100 group-focus-visible:opacity-100">
{hotspot.label}
</span>
</button>
))}
{showItems && activeScene.infoMarkers?.map((marker) => {
const markerKey = `${activeScene.id}-${marker.id}`;
const isRead = readMarkers.has(markerKey);
const isOpening = openingInfoMarkerId === marker.id;
const isHovered = hoveredMarkerId === markerKey;
const isIntroMarker = marker.id === "gioi-thieu-cum-di-tich";
return (
<button
key={markerKey}
ref={(node) => {
if (node) {
infoMarkerRefs.current.set(markerKey, node);
} else {
infoMarkerRefs.current.delete(markerKey);
}
}}
type="button"
className={`group absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center opacity-0 drop-shadow-[0_16px_30px_rgba(0,0,0,0.4)] transition-[opacity,transform,filter,scale] duration-500 ease-[cubic-bezier(.2,.9,.2,1)] hover:scale-[1.045] hover:drop-shadow-[0_20px_38px_rgba(0,0,0,0.48)] active:scale-[0.97] ${isIntroMarker ? "h-[5.25rem] w-[6.25rem] sm:h-[5.75rem] sm:w-[6.75rem]" : "h-[7.35rem] w-[10rem] sm:h-[8.15rem] sm:w-[11rem]"
}`}
data-opening={isOpening ? "true" : undefined}
data-read={isRead ? "true" : undefined}
style={
{
"--marker-angle": `${marker.rotation ?? 0}deg`,
} as CSSProperties
}
onMouseEnter={() => setHoveredMarkerId(markerKey)}
onMouseLeave={() => setHoveredMarkerId(null)}
onClick={() => {
if (openingInfoTimerRef.current) {
window.clearTimeout(openingInfoTimerRef.current);
}
setActivePanel(null);
setIsInfoMarkerClosing(false);
if (isRead) {
setActiveInfoMarker(marker);
setOpeningInfoMarkerId(null);
return;
}
setOpeningInfoMarkerId(marker.id);
openingInfoTimerRef.current = window.setTimeout(() => {
setReadMarkers((prev) => new Set(prev).add(markerKey));
setActiveInfoMarker(marker);
setOpeningInfoMarkerId(null);
openingInfoTimerRef.current = null;
}, 520);
}}
aria-label={`Mở thông tin ${marker.title}`}
title={marker.title}
>
{/* TÌM ĐOẠN NÀY VÀ THAY TOÀN BỘ */}
{marker.iconType === "envelope" ? (
<>
{!isRead && (
<>
<span className="tour-envelope-glow absolute inset-[-1.5rem] rounded-[42%]" />
<span className="tour-envelope-aura absolute bottom-5 left-1/2 h-7 w-[72%] -translate-x-1/2 rounded-full" />
</>
)}
<EnvelopeLottie
isOpening={isOpening}
isRead={isRead}
isHovered={isHovered}
rotation={marker.rotation ?? 0}
/>
</>
) : (
<div
className={`
relative grid place-items-center overflow-visible rounded-full border
backdrop-blur-md
transition-all duration-300
${marker.iconType === "info"
? "h-12 w-12 border-[rgba(232,207,170,0.86)] bg-[radial-gradient(circle_at_50%_34%,rgba(255,246,206,0.3),rgba(143,57,45,0.88)_62%,rgba(72,31,25,0.94))] shadow-[0_0_0_3px_rgba(232,207,170,0.14),0_0_22px_rgba(232,207,170,0.34),0_10px_24px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,248,220,0.5)] sm:h-14 sm:w-14"
: "h-14 w-14 border-[rgba(255,255,255,0.3)] bg-[rgba(255,255,255,0.08)] shadow-[0_0_16px_rgba(255,255,255,0.15),0_8px_24px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.4)]"}
${isHovered
? marker.iconType === "info"
? "scale-110 border-[rgba(255,234,176,1)] shadow-[0_0_0_5px_rgba(232,207,170,0.18),0_0_30px_rgba(255,210,115,0.52),0_14px_30px_rgba(0,0,0,0.38),inset_0_1px_0_rgba(255,250,230,0.66)]"
: "scale-125 bg-[rgba(255,255,255,0.18)] border-[rgba(255,255,255,0.6)] shadow-[0_0_28px_rgba(255,255,255,0.35),inset_0_1px_0_rgba(255,255,255,0.6)]"
: "scale-100"}
${isRead ? "opacity-50" : "opacity-100"}
`}
>
{marker.iconType === "info" ? (
<>
{!isRead ? (
<span className="tour-temple-icon-pulse absolute inset-[-0.45rem] -z-10 rounded-full" />
) : null}
<span className="absolute inset-[0.32rem] rounded-full border border-[rgba(255,238,184,0.3)]" />
<BookOpenText
className={`relative h-6 w-6 transition-colors duration-300 sm:h-7 sm:w-7 ${isHovered ? "text-white" : "text-[rgb(255,238,184)]"}`}
strokeWidth={1.95}
/>
</>
) : (
<Landmark
className={`h-7 w-7 transition-colors duration-300 ${isHovered ? "text-white" : "text-[rgba(255,255,255,0.82)]"}`}
strokeWidth={1.6}
/>
)}
</div>
)}
<span className="pointer-events-none absolute left-1/2 top-[calc(100%+0.42rem)] max-w-[11rem] -translate-x-1/2 whitespace-nowrap rounded-[7px] border border-[rgb(232_207_170_/_0.24)] bg-[rgb(45_38_33_/_0.72)] px-3 py-1.5 text-[0.72rem] font-bold text-white opacity-0 shadow-[0_12px_28px_rgba(0,0,0,0.34)] backdrop-blur-xl transition group-hover:opacity-100 group-focus-visible:opacity-100">
{marker.title}
</span>
</button>
);
})}
</div>
{activeInfoMarker ? (
<div
className={`fixed inset-0 z-40 grid place-items-center bg-[rgb(22_18_15_/_0.44)] px-3 py-4 backdrop-blur-[3px] sm:px-5 sm:py-6 ${isInfoMarkerClosing
? "animate-[tourModalBackdropOut_240ms_ease-in_both]"
: "animate-[tourModalBackdrop_320ms_ease-out_both]"
}`}
>
<button
type="button"
className="absolute inset-0 cursor-default"
onClick={closeInfoMarker}
aria-label="Đóng thư"
/>
<article
className={`tour-letter-shell relative h-[min(90dvh,820px)] w-[min(90vw,560px)] overflow-hidden rounded-[13px] text-[var(--foreground)] shadow-[0_38px_120px_rgb(0_0_0_/_0.46)] sm:w-[min(82vw,590px)] ${isInfoMarkerClosing ? "tour-letter-shell--closing" : ""
}`}
>
<Image
src={infoModalBackground}
alt=""
fill
sizes="760px"
className="pointer-events-none select-none object-fill"
draggable={false}
/>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgb(255_252_245_/_0.1),rgb(245_238_220_/_0.08))]" />
<button
type="button"
onClick={closeInfoMarker}
className="absolute right-[7.6%] top-[5.8%] z-10 grid h-8 w-8 place-items-center rounded-full border border-[rgb(58_47_35_/_0.18)] bg-[rgb(255_252_245_/_0.76)] text-[var(--tour-wood)] shadow-[0_8px_24px_rgb(74_63_53_/_0.12)] backdrop-blur-sm transition hover:bg-white active:scale-95 sm:h-9 sm:w-9"
aria-label="Đóng thư"
title="Đóng"
>
<X className="h-4 w-4 sm:h-[1.1rem] sm:w-[1.1rem]" strokeWidth={2.2} />
</button>
<div className="tour-letter-content relative h-full overflow-y-auto px-[9%] pb-[11%] pt-[11%] sm:px-[10.5%] sm:pb-[10%] sm:pt-[10%]">
<div className="mb-4 pr-10 animate-[tourTextFadeIn_420ms_ease-out_80ms_both] sm:mb-5">
<div className="min-w-0">
<p className="text-[0.62rem] font-black uppercase tracking-[0.18em] text-[var(--tour-gold)] sm:text-[0.66rem]">
{activeInfoMarker.eyebrow}
</p>
<h2 className="font-display-vn mt-0.5 text-[1.85rem] font-bold leading-tight text-[var(--tour-gold)] sm:text-[2.55rem]">
{activeInfoMarker.title}
</h2>
</div>
</div>
<div className="space-y-3.5 border-y border-[rgb(166_124_82_/_0.2)] py-4 font-display-vn text-[0.92rem] leading-7 text-[rgb(74_63_53_/_0.92)] animate-[tourTextFadeIn_480ms_ease-out_180ms_both] sm:space-y-4 sm:py-5 sm:text-[0.98rem] sm:leading-8">
{activeInfoMarker.content.map((paragraph, index) => (
<p
key={paragraph}
className="text-justify animate-[tourTextSlideIn_420ms_ease-out_both]"
style={{ animationDelay: `${280 + index * 60}ms` }}
>
{paragraph}
</p>
))}
</div>
<div className="mt-4 flex items-center justify-between gap-4 text-[0.62rem] font-black uppercase tracking-[0.16em] text-[rgb(115_90_58_/_0.62)] animate-[tourTextFadeIn_380ms_ease-out_420ms_both] sm:mt-5 sm:text-[0.66rem]">
<span>{tourConfig.title}</span>
<span className="h-px flex-1 bg-[linear-gradient(90deg,rgb(166_124_82_/_0.3),transparent)]" />
</div>
</div>
</article>
</div>
) : null}
{activePanel === "map" ? (
<div className="fixed inset-0 z-[25] grid place-items-center bg-[rgb(22_18_15_/_0.44)] px-3 pb-[6.8rem] pt-4 backdrop-blur-[3px] sm:px-5 sm:pb-[7.8rem] sm:pt-6">
<button
type="button"
className="absolute inset-0 cursor-default"
onClick={() => setActivePanel(null)}
aria-label="Đóng vị trí"
/>
<section className="relative grid max-h-[calc(100dvh-8.5rem)] w-[calc(100vw-1.5rem)] max-w-[980px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden rounded-[8px] border border-[rgb(232_207_170_/_0.2)] bg-[linear-gradient(135deg,rgb(255_252_245_/_0.13),rgb(255_252_245_/_0.045)_40%,transparent_72%),rgb(58_50_44_/_0.82)] text-white shadow-[0_38px_120px_rgb(0_0_0_/_0.46),inset_0_1px_0_rgb(255_255_255_/_0.16)] backdrop-blur-2xl sm:w-[calc(100vw-2.5rem)] sm:max-h-[calc(100dvh-9.5rem)] lg:max-w-[1080px]">
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<h2 className="text-xl font-black tracking-[-0.02em] sm:text-2xl">Vị trí</h2>
<button
type="button"
onClick={() => setActivePanel(null)}
className="grid h-9 w-9 shrink-0 place-items-center rounded-[8px] border border-white/12 bg-white/[0.06] text-white transition hover:bg-white/[0.12] active:scale-95"
aria-label="Đóng vị trí"
title="Đóng"
>
<X className="h-4 w-4" strokeWidth={2.4} />
</button>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto p-3 [scrollbar-color:var(--tour-gold)_transparent] [scrollbar-width:thin] sm:p-4 lg:grid lg:grid-cols-[minmax(0,7fr)_minmax(260px,3fr)] lg:overflow-hidden">
<MiniMap
tourConfig={tourConfig}
activeScene={activeScene}
onSceneSelect={(sceneId) => goToScene(sceneId, true)}
cameraYaw={cameraYaw}
mode="overview"
className="min-h-[280px] shrink-0 lg:h-full lg:min-h-0"
mapClassName="lg:min-h-[430px]"
/>
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col rounded-[7px] border border-[rgb(255_252_245_/_0.14)] bg-[rgb(255_252_245_/_0.06)] p-3 sm:p-4">
<div className="shrink-0">
<p className="text-[0.7rem] font-black uppercase tracking-[0.14em] text-[var(--tour-gold-light)]">
Bạn đang đây
</p>
<h3 className="mt-1 text-xl font-black tracking-[-0.02em] text-white sm:text-2xl">
{activeScene.title}
</h3>
<p className="mt-1 text-[0.9rem] font-semibold text-white/62">{activeScene.location}</p>
</div>
<div className="mt-4 grid min-h-0 gap-2 overflow-y-auto pr-1 [scrollbar-color:var(--tour-gold)_transparent] [scrollbar-width:thin]">
{tourConfig.locationGroups.map((scene) => {
const isActiveLocation = scene.location === activeScene.location;
return (
<button
key={`location-${scene.location}`}
type="button"
onClick={() => goToScene(scene.id, true)}
aria-current={isActiveLocation ? "true" : undefined}
className={`group relative h-[5.8rem] overflow-hidden rounded-[7px] border text-left transition-[border-color,box-shadow,filter] duration-300 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--tour-gold-light)] focus-visible:ring-offset-2 focus-visible:ring-offset-[rgb(45_38_33_/_0.62)] active:scale-[0.99] ${isActiveLocation
? "border-[var(--tour-gold-light)] bg-[rgb(192_160_128_/_0.16)] shadow-[0_0_0_2px_rgb(232_207_170_/_0.22),0_16px_40px_rgb(0_0_0_/_0.24)]"
: "border-white/12 bg-white/[0.04] shadow-[0_12px_30px_rgb(0_0_0_/_0.16)] hover:border-[rgb(232_207_170_/_0.42)]"
}`}
>
<Image
src={scene.image}
alt={scene.location}
fill
sizes="340px"
className="object-cover transition duration-500 group-hover:scale-[1.03] group-hover:saturate-[1.06]"
/>
<span className="absolute inset-0 bg-[linear-gradient(90deg,rgba(0,0,0,0.7)_0%,rgba(0,0,0,0.48)_48%,rgba(0,0,0,0.18)_100%)]" />
<span className="absolute inset-x-3 bottom-3 min-w-0">
<span className="block truncate text-[1rem] font-black text-white">
{scene.location}
</span>
</span>
{isActiveLocation ? (
<span className="absolute right-2.5 top-2.5 rounded-full border border-[rgb(255_252_245_/_0.42)] bg-[rgb(232_48_48_/_0.86)] px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.08em] text-white shadow-[0_8px_24px_rgb(0_0_0_/_0.24)]">
Hiện tại
</span>
) : null}
</button>
);
})}
</div>
</aside>
</div>
</section>
</div>
) : null}
<div
className={`absolute left-1/2 z-30 -translate-x-1/2 transition-[bottom,width,max-width,transform] duration-300 ease-out ${activePanel === "scenes" ? "w-[calc(100vw-32px)] max-w-[1180px]" : "w-[min(92vw,448px)]"
} ${activePanel === "scenes" ? "bottom-1 sm:bottom-2" : "bottom-2 sm:bottom-5"}`}
>
{activePanel && activePanel !== "map" ? (
<section className="mb-1.5 max-h-[46dvh] animate-[tourPanelIn_260ms_cubic-bezier(.2,.9,.2,1)_both] overflow-hidden rounded-[6px] border border-[rgb(255_252_245_/_0.18)] bg-[linear-gradient(135deg,rgb(255_252_245_/_0.12),rgb(255_252_245_/_0.04)_38%,transparent_70%),rgb(45_38_33_/_0.48)] shadow-[0_24px_86px_rgb(0_0_0_/_0.28),inset_0_1px_0_rgb(255_255_255_/_0.18),inset_0_-1px_0_rgb(0_0_0_/_0.16)] backdrop-blur-xl backdrop-saturate-150 sm:mb-2.5">
<div className="flex items-center justify-between border-b border-[rgb(255_252_245_/_0.1)] bg-[rgb(255_252_245_/_0.035)] px-3 py-2">
<div className="flex items-center gap-1.5 text-[0.8rem] font-semibold text-white">
{panelTitle(activePanel)}
</div>
<button
type="button"
onClick={() => setActivePanel(null)}
className="grid h-7 w-7 place-items-center rounded-[6px] text-white transition hover:bg-white/10 active:scale-95"
aria-label="Đóng bảng"
>
<X className="h-4 w-4" strokeWidth={2.4} />
</button>
</div>
{activePanel === "scenes" ? (
<div className="flex w-full gap-2.5 overflow-x-auto overflow-y-hidden px-2.5 py-2.5 [scrollbar-color:var(--tour-gold)_transparent] [scrollbar-width:thin]">
{tourConfig.scenes.map((scene) => (
<button
key={scene.id}
type="button"
onClick={() => goToScene(scene.id, false)}
aria-current={scene.id === activeScene.id ? "true" : undefined}
className={`group relative h-20 w-[60vw] shrink-0 overflow-hidden rounded-[6px] border text-left transition-[border-color,box-shadow,filter] duration-300 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--tour-gold-light)] focus-visible:ring-offset-2 focus-visible:ring-offset-[rgb(45_38_33_/_0.62)] active:scale-[0.99] sm:h-24 sm:w-48 lg:w-56 ${scene.id === activeScene.id
? "border-[var(--tour-gold-light)] bg-[rgb(192_160_128_/_0.16)] shadow-[0_0_0_2px_rgb(232_207_170_/_0.28),0_16px_40px_rgb(0_0_0_/_0.28)]"
: "border-white/14 bg-white/[0.04] shadow-[0_12px_30px_rgb(0_0_0_/_0.18)] hover:border-[rgb(232_207_170_/_0.46)] hover:shadow-[0_16px_38px_rgb(0_0_0_/_0.24)]"
}`}
>
<Image src={scene.image} alt={scene.title} fill sizes="220px" className="object-cover transition duration-500 group-hover:saturate-[1.06]" />
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.03)_22%,rgba(0,0,0,0.68)_100%)] opacity-90" />
<span className="pointer-events-none absolute inset-x-0 top-0 h-1/2 bg-[linear-gradient(180deg,rgb(255_252_245_/_0.18),transparent)] opacity-55" />
<span className="absolute inset-x-3 top-2 h-px bg-[linear-gradient(90deg,transparent,rgb(255_252_245_/_0.52),transparent)]" />
<span className="absolute left-2.5 top-2.5 grid h-6 min-w-8 place-items-center rounded-full bg-[rgb(255_252_245_/_0.84)] px-2 font-mono text-[10px] font-bold text-[var(--tour-wood)] shadow-[0_8px_22px_rgb(0_0_0_/_0.18)]">
{scene.order}
</span>
<span className="absolute inset-x-3 bottom-2 min-w-0">
<span className="block truncate text-[0.82rem] font-semibold text-white">
{scene.title}
</span>
<span className="mt-0.5 block truncate text-[0.7rem] text-white/58">
{scene.location}
</span>
</span>
{scene.id === activeScene.id ? (
<span className="absolute right-2.5 top-2.5 rounded-full border border-[rgb(255_252_245_/_0.42)] bg-[rgb(49_95_80_/_0.86)] px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.08em] text-white shadow-[0_8px_24px_rgb(0_0_0_/_0.24)]">
Đang xem
</span>
) : null}
</button>
))}
</div>
) : null}
{activePanel === "info" ? (
<div className="max-h-[calc(46dvh-44px)] space-y-3 overflow-y-auto p-3 pr-2 text-[0.82rem] leading-5 text-white/76 [scrollbar-color:var(--tour-gold)_transparent] [scrollbar-width:thin]">
<p className="text-white/82">{tourConfig.practicalInfo.intro}</p>
<div className="grid gap-2">
{tourConfig.practicalInfo.items.map((item) => (
<div
key={item.label}
className="rounded-[6px] border border-[rgb(255_252_245_/_0.1)] bg-[rgb(255_252_245_/_0.055)] px-3 py-2.5"
>
<p className="text-[0.66rem] font-black uppercase tracking-[0.1em] text-[var(--tour-gold-light)]">
{item.label}
</p>
<p className="mt-1 text-white/82">{item.value}</p>
</div>
))}
</div>
<div className="rounded-[6px] border border-[rgb(232_207_170_/_0.16)] bg-[rgb(49_95_80_/_0.18)] px-3 py-2.5">
<p className="font-semibold text-white">
Đang tham quan: {activeScene.title}
</p>
<ul className="mt-1.5 space-y-1.5">
{tourConfig.practicalInfo.notes.map((note) => (
<li key={note} className="flex gap-2">
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-[var(--tour-gold-light)]" />
<span>{note}</span>
</li>
))}
</ul>
</div>
</div>
) : null}
{activePanel === "settings" ? (
<div className="grid gap-2 p-2.5 sm:grid-cols-2">
<ToggleButton
active={autoRotate}
icon={autoRotate ? Pause : Play}
label="Tự xoay"
onClick={() => setAutoRotate((value) => !value)}
/>
<ToggleButton
active={wideAngle}
icon={Scan}
label="Góc rộng"
onClick={() => setWideAngle((value) => !value)}
/>
<ToggleButton
active={!showItems}
icon={showItems ? Eye : EyeOff}
label={showItems ? "Ẩn vật phẩm" : "Hiện vật phẩm"}
onClick={() => setShowItems((value) => !value)}
/>
</div>
) : null}
</section>
) : null}
{activePanel !== "scenes" ? (
<nav className="grid grid-cols-5 gap-1 overflow-hidden rounded-[6px] border border-[rgb(255_252_245_/_0.2)] bg-[linear-gradient(135deg,rgb(255_252_245_/_0.14),transparent_36%),rgb(115_90_58_/_0.62)] p-1.5 text-white shadow-[0_24px_80px_rgba(0,0,0,0.32),inset_0_1px_0_rgb(255_255_255_/_0.18)] backdrop-blur-xl backdrop-saturate-150 transition-all duration-300 ease-out sm:gap-1.5">
<BottomButton
icon={Layers3}
label="Cảnh"
active={false}
onClick={() => setActivePanel("scenes")}
/>
<BottomButton
icon={Info}
label="Thông tin"
active={activePanel === "info"}
onClick={() => setActivePanel(activePanel === "info" ? null : "info")}
/>
<BottomButton
icon={MapPin}
label="Vị trí"
active={activePanel === "map"}
onClick={() => setActivePanel(activePanel === "map" ? null : "map")}
/>
<BottomButton
icon={soundEnabled ? Volume2 : VolumeX}
label="Âm thanh"
active={soundEnabled}
onClick={onToggleSound}
/>
<BottomButton
icon={Settings}
label="Cài đặt"
active={activePanel === "settings"}
onClick={() => setActivePanel(activePanel === "settings" ? null : "settings")}
/>
</nav>
) : null}
</div>
<button
type="button"
onClick={() => void toggleVrMode()}
disabled={!isSupported}
className={`fixed right-3 top-1/2 z-20 inline-flex h-12 min-w-14 -translate-y-1/2 items-center justify-center gap-1.5 rounded-full border px-3 shadow-[0_18px_54px_rgba(0,0,0,0.34)] backdrop-blur-xl transition-all active:scale-95 disabled:cursor-not-allowed disabled:opacity-45 sm:right-4 sm:h-14 sm:min-w-16 ${vrMode
? "border-[var(--tour-gold-light)] bg-[rgb(232_207_170_/_0.24)] text-white"
: "border-white/20 bg-[rgb(45_38_33_/_0.58)] text-white/88 hover:bg-[rgb(45_38_33_/_0.72)]"
}`}
aria-label={isSupported ? (vrMode ? "Tắt VR" : "Bật VR") : "Thiết bị không hỗ trợ VR"}
title={isSupported ? (vrMode ? "Tắt VR" : "Bật VR") : "Thiết bị không hỗ trợ VR"}
>
<Scan className="h-4 w-4 sm:h-5 sm:w-5" strokeWidth={2.2} />
<span className="text-[0.72rem] font-black uppercase tracking-[0.04em] sm:text-[0.78rem]">
VR
</span>
</button>
<button
type="button"
onClick={toggleFullscreen}
className={`fixed right-4 top-4 z-20 hidden rounded-full border p-2.5 shadow-[0_12px_40px_rgba(0,0,0,0.32)] backdrop-blur-xl transition-all active:scale-95 sm:p-3 lg:inline-flex lg:items-center lg:justify-center ${isFullscreen
? "border-[var(--tour-gold-light)] bg-[rgb(192_160_128_/_0.24)] text-white"
: "border-white/20 bg-[rgb(45_38_33_/_0.48)] text-white/80 hover:bg-[rgb(45_38_33_/_0.62)]"
}`}
aria-label={isFullscreen ? "Thoát toàn màn hình" : "Toàn màn hình"}
title={isFullscreen ? "Thoát toàn màn hình" : "Toàn màn hình"}
>
{isFullscreen ? (
<Minimize2 className="h-5 w-5 sm:h-6 sm:w-6" strokeWidth={2} />
) : (
<Maximize2 className="h-5 w-5 sm:h-6 sm:w-6" strokeWidth={2} />
)}
</button>
{loadError ? (
<div className="pointer-events-none absolute inset-0 z-40 grid place-items-center bg-[rgb(45_38_33_/_0.46)] backdrop-blur-sm">
<div className="w-[min(82vw,280px)] rounded-[6px] border border-[rgb(255_252_245_/_0.16)] bg-[rgb(45_38_33_/_0.62)] p-4 text-center shadow-[0_18px_70px_rgba(0,0,0,0.42)] backdrop-blur-2xl">
<p className="text-[0.82rem] font-semibold text-white">{loadError}</p>
</div>
</div>
) : null}
<style jsx global>{`
@keyframes tourPanelIn {
from {
opacity: 0;
transform: translateY(14px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes welcomeCtaSheen {
from {
transform: translateX(-120%) skewX(-18deg);
}
to {
transform: translateX(180%) skewX(-18deg);
}
}
.welcome-cta-sheen {
background: linear-gradient(90deg, transparent, rgba(255, 252, 245, 0.28), transparent);
animation: welcomeCtaSheen 3.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.welcome-title-plaque {
isolation: isolate;
border: 2px solid rgba(185, 139, 86, 0.62);
border-radius: 56px 56px 48px 48px / 42px 42px 36px 36px;
background:
radial-gradient(circle at 50% 0%, rgba(255, 252, 245, 0.94), transparent 24%),
linear-gradient(180deg, rgba(255, 252, 245, 0.96), rgba(246, 234, 210, 0.96));
box-shadow:
0 28px 74px rgba(8, 10, 7, 0.42),
0 0 0 5px rgba(255, 252, 245, 0.66),
0 0 0 7px rgba(166, 124, 82, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -14px 28px rgba(166, 124, 82, 0.12);
}
.welcome-title-plaque::before,
.welcome-title-plaque::after {
content: "";
position: absolute;
top: 30%;
z-index: -1;
width: 5.25rem;
height: 6.1rem;
border: 2px solid rgba(185, 139, 86, 0.55);
background:
linear-gradient(180deg, rgba(255, 252, 245, 0.96), rgba(246, 234, 210, 0.96));
box-shadow:
0 0 0 5px rgba(255, 252, 245, 0.62),
0 0 0 7px rgba(166, 124, 82, 0.34),
0 22px 54px rgba(8, 10, 7, 0.28);
}
.welcome-title-plaque::before {
left: -2.6rem;
border-radius: 999px 0 0 999px;
border-right: 0;
}
.welcome-title-plaque::after {
right: -2.6rem;
border-left: 0;
border-radius: 0 999px 999px 0;
}
.welcome-plaque-crown {
position: absolute;
left: 50%;
top: -3.05rem;
z-index: -1;
width: 9.5rem;
height: 5.8rem;
transform: translateX(-50%);
border: 2px solid rgba(185, 139, 86, 0.58);
border-bottom: 0;
border-radius: 999px 999px 0 0;
background:
radial-gradient(circle at 50% 52%, rgba(185, 139, 86, 0.28) 0 0.34rem, transparent 0.36rem),
linear-gradient(180deg, rgba(255, 252, 245, 0.96), rgba(246, 234, 210, 0.96));
box-shadow:
0 0 0 5px rgba(255, 252, 245, 0.62),
0 0 0 7px rgba(166, 124, 82, 0.34);
}
.welcome-plaque-crown::before,
.welcome-plaque-crown::after {
content: "";
position: absolute;
left: 50%;
background: rgba(185, 139, 86, 0.72);
transform: translateX(-50%) rotate(45deg);
}
.welcome-plaque-crown::before {
top: 1.45rem;
width: 1.15rem;
height: 1.15rem;
border-radius: 0.16rem;
}
.welcome-plaque-crown::after {
top: 2.72rem;
width: 4.4rem;
height: 1px;
border-radius: 999px;
transform: translateX(-50%);
}
.welcome-plaque-ornament {
position: absolute;
top: 50%;
width: 5.8rem;
height: 2.8rem;
transform: translateY(-8%);
opacity: 0.34;
background:
radial-gradient(ellipse at 24% 54%, transparent 0 0.65rem, rgba(185, 139, 86, 0.76) 0.68rem 0.76rem, transparent 0.8rem),
radial-gradient(ellipse at 44% 54%, transparent 0 0.44rem, rgba(185, 139, 86, 0.62) 0.47rem 0.54rem, transparent 0.58rem),
linear-gradient(90deg, transparent 0%, rgba(185, 139, 86, 0.74) 52%, transparent 100%);
background-size: 100% 100%, 100% 100%, 100% 1px;
background-position: center, center, center;
background-repeat: no-repeat;
}
.welcome-plaque-ornament--left {
left: 2.4rem;
}
.welcome-plaque-ornament--right {
right: 2.4rem;
transform: translateY(-8%) scaleX(-1);
}
@keyframes tourLetterIn {
from {
opacity: 0;
clip-path: inset(42% 12% 42% 12% round 14px);
transform: translateY(22px) rotateX(72deg) scale(0.86);
filter: brightness(1.14) saturate(0.92);
}
58% {
opacity: 1;
clip-path: inset(8% 4% 8% 4% round 14px);
transform: translateY(0) rotateX(-5deg) scale(1.015);
filter: brightness(1.05) saturate(1);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0 round 14px);
transform: translateY(0) rotateX(0deg) scale(1);
filter: brightness(1) saturate(1);
}
}
@keyframes tourLetterOut {
from {
opacity: 1;
clip-path: inset(0 0 0 0 round 14px);
transform: translateY(0) rotateX(0deg) scale(1);
filter: brightness(1) saturate(1);
}
to {
opacity: 0;
clip-path: inset(38% 10% 38% 10% round 14px);
transform: translateY(18px) rotateX(52deg) scale(0.9);
filter: brightness(1.08) saturate(0.92);
}
}
@keyframes tourModalBackdrop {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(3px);
}
}
@keyframes tourModalBackdropOut {
from {
opacity: 1;
backdrop-filter: blur(3px);
}
to {
opacity: 0;
backdrop-filter: blur(0px);
}
}
@keyframes tourTextFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tourTextSlideIn {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tourEnvelopeGlow {
0%,
100% {
opacity: 0.34;
transform: translate3d(0, 1px, 0) scale(0.98);
}
50% {
opacity: 0.58;
transform: translate3d(0, -1px, 0) scale(1.025);
}
}
@keyframes tourEnvelopeOpenFlash {
0% {
opacity: 0.3;
transform: scale(0.96);
}
36% {
opacity: 0.72;
transform: scale(1.06);
}
100% {
opacity: 0;
transform: scale(1.12);
}
}
@keyframes tourTempleIconPulse {
0%,
100% {
opacity: 0.34;
transform: scale(0.92);
box-shadow:
0 0 0 0 rgba(232, 207, 170, 0.24),
0 0 22px rgba(232, 207, 170, 0.34);
}
50% {
opacity: 0.78;
transform: scale(1.12);
box-shadow:
0 0 0 10px rgba(232, 207, 170, 0.08),
0 0 42px rgba(255, 216, 132, 0.62);
}
}
.tour-envelope-glow {
background:
radial-gradient(ellipse at 52% 58%, rgba(255, 225, 145, 0.42), rgba(217, 161, 86, 0.16) 38%, transparent 72%),
linear-gradient(115deg, transparent 12%, rgba(255, 252, 245, 0.09) 42%, transparent 68%);
filter: blur(14px);
animation: tourEnvelopeGlow 4.4s cubic-bezier(0.34, 1, 0.64, 1) infinite;
}
.tour-envelope-aura {
background: linear-gradient(90deg, transparent, rgba(255, 216, 132, 0.34), transparent);
filter: blur(10px);
opacity: 0.72;
}
.tour-temple-icon-pulse {
background:
radial-gradient(circle, rgba(255, 226, 150, 0.34), rgba(184, 45, 34, 0.16) 46%, transparent 72%);
animation: tourTempleIconPulse 2.4s ease-in-out infinite;
}
[data-opening="true"] .tour-envelope-glow {
animation: tourEnvelopeOpenFlash 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
[data-read="true"] .tour-envelope-glow,
[data-read="true"] .tour-envelope-aura {
opacity: 0;
animation-play-state: paused;
}
.tour-letter-shell {
animation: tourLetterIn 520ms cubic-bezier(0.16, 0.9, 0.22, 1) both;
transform-origin: 50% 72%;
transform-style: preserve-3d;
}
.tour-letter-shell--closing {
animation: tourLetterOut 240ms cubic-bezier(0.4, 0, 0.8, 0.2) both;
pointer-events: none;
}
.tour-letter-shell::before {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(130deg, rgba(255, 255, 255, 0.26), transparent 34%, rgba(96, 70, 38, 0.09) 100%);
mix-blend-mode: soft-light;
}
.tour-letter-content {
scrollbar-color: rgba(166, 124, 82, 0.52) transparent;
scrollbar-width: thin;
}
.tour-canvas {
--tour-blur: 0px;
--tour-scale: 1;
--tour-vignette: 0;
overflow: hidden;
}
.tour-canvas::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
circle at 50% 50%,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.18) 58%,
rgba(0, 0, 0, 0.32) 100%
);
opacity: var(--tour-vignette);
transition: opacity 120ms linear;
}
.tour-canvas canvas {
display: block;
filter: blur(var(--tour-blur));
transform: scale(var(--tour-scale)) translateZ(0);
will-change: transform, filter;
transition: filter 120ms cubic-bezier(0.4, 0, 0.2, 1),
transform 120ms cubic-bezier(0.4, 0, 0.2, 1);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
@media (prefers-reduced-motion: reduce) {
.welcome-cta-sheen {
animation: none;
opacity: 0;
}
}
@media (prefers-reduced-transparency: reduce) {
.welcome-title-plaque {
background: rgb(255 252 245 / 0.94);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
@media (max-width: 640px) {
.welcome-title-plaque::before,
.welcome-title-plaque::after,
.welcome-plaque-ornament {
display: none;
}
.welcome-plaque-crown {
top: -2.4rem;
width: 7rem;
height: 4.4rem;
}
}
`}</style>
</main>
);
}
function panelTitle(panel: Exclude<Panel, null>) {
if (panel === "scenes") {
return "Danh sách cảnh";
}
if (panel === "info") {
return "Thông tin";
}
if (panel === "map") {
return "Vị trí";
}
return "Cài đặt";
}
function MiniMap({
tourConfig,
activeScene,
className = "",
compact = false,
onSceneSelect,
cameraYaw = 0,
mapClassName = "",
mode = "radar",
showLegend = false,
showNames = false,
}: {
tourConfig: TourConfig;
activeScene: TourScene;
className?: string;
compact?: boolean;
onSceneSelect: (sceneId: SceneId) => void;
cameraYaw?: number;
mapClassName?: string;
mode?: "radar" | "overview";
showLegend?: boolean;
showNames?: boolean;
}) {
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)),
};
}
const dx = scene.mapPosition.x - activeScene.mapPosition.x;
const dy = scene.mapPosition.y - activeScene.mapPosition.y;
const relativeYaw = cameraYaw - activeScene.initialYaw;
const angleRad = (-relativeYaw * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const rotatedX = dx * cos - dy * sin;
const rotatedY = dx * sin + dy * cos;
return {
x: clampMapPoint(50 + rotatedX),
y: clampMapPoint(50 + rotatedY),
};
};
return (
<section
className={`relative overflow-hidden rounded-[6px] border border-[rgb(255_252_245_/_0.18)] bg-[linear-gradient(135deg,rgb(255_252_245_/_0.07),rgb(255_252_245_/_0.018)_45%,transparent),rgb(45_38_33_/_0.18)] shadow-[0_18px_70px_rgba(0,0,0,0.32),inset_0_1px_0_rgb(255_255_255_/_0.12)] backdrop-blur-xl backdrop-saturate-150 ${compact ? "p-2.5" : "min-h-[260px] p-3"
} ${className}`}
aria-label="Mini-map vị trí tham quan"
>
<div className={`mb-2 flex items-center justify-between gap-2 ${compact ? "pr-24" : ""}`}>
<div className="min-w-0">
<p className="truncate text-[0.68rem] font-black uppercase tracking-[0.12em] text-[var(--tour-gold-light)]">
{isOverview ? "Sơ đồ tuyến" : "Mini-map"}
</p>
<p className="truncate text-[0.74rem] font-semibold text-white sm:text-[0.8rem]">
{activeScene.title}
</p>
</div>
</div>
<div
className={`relative overflow-hidden rounded-[5px] border border-white/[0.07] bg-[radial-gradient(circle_at_50%_76%,rgb(232_207_170_/_0.055),transparent_24%),linear-gradient(180deg,rgb(255_252_245_/_0.026),rgb(0_0_0_/_0.025))] ${compact ? "aspect-[4/3]" : "aspect-[4/3] min-h-[240px] sm:aspect-[16/10]"
} ${mapClassName}`}
>
<svg
className="absolute inset-0 h-full w-full transition-opacity duration-200 ease-out"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
{mapRoutes.map((edge, index) => {
const fromScene = sceneById.get(edge.from);
const toScene = sceneById.get(edge.to);
if (!fromScene?.mapPosition || !toScene?.mapPosition) {
return null;
}
const fromPos = !isOverview && fromScene.id === activeScene.id
? { x: 50, y: 50 }
: getMapPosition(fromScene);
const toPos = !isOverview && toScene.id === activeScene.id
? { x: 50, y: 50 }
: getMapPosition(toScene);
if (!fromPos || !toPos) return null;
return (
<line
key={`${edge.from}-${edge.to}-${index}`}
x1={fromPos.x}
y1={fromPos.y}
x2={toPos.x}
y2={toPos.y}
stroke="rgba(232,207,170,0.42)"
strokeWidth={compact ? 0.9 : 1.05}
strokeLinecap="round"
className="transition-all duration-200"
/>
);
})}
</svg>
{scenes.map((scene) => {
if (!scene?.mapPosition) {
return null;
}
const isActive = scene.id === activeScene.id;
const mapPos = !isOverview && isActive ? { x: 50, y: 50 } : getMapPosition(scene);
if (!mapPos) return null;
return (
<div
key={`map-point-wrap-${scene.id}`}
className="absolute z-10 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1"
style={{ left: `${mapPos.x}%`, top: `${mapPos.y}%` }}
>
<button
type="button"
onClick={() => onSceneSelect(scene.id)}
aria-current={isActive ? "true" : undefined}
aria-label={`${scene.order} - ${scene.title}`}
title={scene.title}
className={`group relative grid place-items-center rounded-full border text-[0.58rem] font-black transition-[transform,background-color,border-color,box-shadow] duration-200 active:scale-95 ${isActive ? "h-6 w-6 border-white bg-[#e83030] text-white shadow-[0_0_0_4px_rgb(232_48_48_/_0.22),0_10px_24px_rgb(0_0_0_/_0.38)]" : "h-5 w-5 border-[rgb(232_207_170_/_0.68)] bg-[rgb(45_38_33_/_0.82)] text-[var(--tour-gold-light)] hover:border-white hover:bg-[var(--tour-jade)]"} ${compact && !isActive ? "sm:h-5 sm:w-5" : "sm:h-6 sm:w-6"}`}
>
{scene.order}
</button>
{showNames ? (
<span className={`max-w-[8.5rem] truncate rounded-[5px] border px-1.5 py-0.5 text-center text-[0.58rem] font-bold leading-none shadow-[0_8px_20px_rgba(0,0,0,0.22)] backdrop-blur-xl ${isActive ? "border-[rgb(232_48_48_/_0.3)] bg-[rgb(232_48_48_/_0.22)] text-white" : "border-white/10 bg-[rgb(45_38_33_/_0.62)] text-white/82"}`}>
{scene.title}
</span>
) : null}
</div>
);
})}
</div>
{showLegend ? (
<div className="mt-3 grid max-h-[30dvh] gap-1.5 overflow-y-auto pr-1 [scrollbar-color:var(--tour-gold)_transparent] [scrollbar-width:thin] sm:grid-cols-2 lg:grid-cols-3">
{scenes.map((scene) => (
<button
key={`map-note-${scene.id}`}
type="button"
onClick={() => onSceneSelect(scene.id)}
aria-current={scene.id === activeScene.id ? "true" : undefined}
className={`flex items-center gap-2 rounded-[7px] border px-2.5 py-2 text-left transition hover:border-[var(--tour-gold-light)] hover:bg-white/[0.08] active:scale-[0.99] ${scene.id === activeScene.id ? "border-[rgb(232_48_48_/_0.42)] bg-[rgb(232_48_48_/_0.16)]" : "border-white/10 bg-white/[0.04]"}`}
>
<span className={`grid h-6 w-6 shrink-0 place-items-center rounded-full text-[0.62rem] font-black ${scene.id === activeScene.id ? "bg-[#e83030] text-white" : "bg-[rgb(45_38_33_/_0.86)] text-[var(--tour-gold-light)]"}`}>
{scene.order}
</span>
<span className="min-w-0">
<span className="block truncate text-[0.78rem] font-bold text-white">{scene.title}</span>
<span className="block truncate text-[0.64rem] text-white/52">{scene.location}</span>
</span>
</button>
))}
</div>
) : null}
</section>
);
}
function EnvelopeLottie({
isOpening,
isRead,
isHovered,
rotation,
}: {
isOpening: boolean;
isRead: boolean;
isHovered: boolean;
rotation: number;
}) {
const lottieRef = useRef<LottieRefCurrentProps | null>(null);
const wasHoveredRef = useRef(false);
useEffect(() => {
const player = lottieRef.current;
if (!player) return;
if (isOpening) {
player.setSpeed(1);
player.setDirection(1);
player.playSegments([0, 65], true);
wasHoveredRef.current = false;
return;
}
if (isRead) {
player.setSpeed(1);
player.setDirection(1);
player.goToAndStop(65, true);
wasHoveredRef.current = false;
return;
}
if (isHovered) {
player.setSpeed(1);
player.setDirection(1);
player.playSegments([0, 42], true);
wasHoveredRef.current = true;
return;
}
if (wasHoveredRef.current) {
player.setSpeed(1);
player.setDirection(1);
player.playSegments([42, 0], true);
wasHoveredRef.current = false;
return;
}
player.setSpeed(1);
player.setDirection(1);
player.goToAndStop(0, true);
}, [isOpening, isRead, isHovered]);
return (
<span className="relative h-[6.75rem] w-[9.15rem] [transform:rotate(var(--marker-angle))] sm:h-[7.55rem] sm:w-[10.15rem]" style={{ "--marker-angle": `${rotation}deg` } as CSSProperties}>
<Lottie
lottieRef={lottieRef}
animationData={letterAnimation}
loop={false}
autoplay={false}
className="h-full w-full"
style={{
filter: isRead
? "brightness(0.9) saturate(0.86) sepia(0.2) hue-rotate(-12deg) drop-shadow(0 6px 12px rgba(0,0,0,0.28))"
: "brightness(1.05) saturate(1.08) sepia(0.08) hue-rotate(-6deg) drop-shadow(0 0 12px rgba(255,224,150,0.2)) drop-shadow(0 9px 18px rgba(0,0,0,0.32))",
}}
/>
</span>
);
}
function BottomButton({
active,
hideLabel = false,
icon: Icon,
label,
onClick,
}: {
active: boolean;
hideLabel?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex min-w-0 flex-col items-center justify-center gap-1 rounded-[6px] px-1.5 py-2 text-center transition active:scale-95 ${active ? "bg-[rgb(255_252_245_/_0.2)] text-white shadow-[inset_0_1px_0_rgb(255_255_255_/_0.18)]" : "text-white/92 hover:bg-white/12"
}`}
aria-label={label}
title={label}
>
<Icon className="h-5 w-5 sm:h-6 sm:w-6" strokeWidth={2.1} />
{hideLabel ? null : (
<span className="w-full truncate text-[10px] font-semibold leading-tight sm:text-[0.82rem]">
{label}
</span>
)}
</button>
);
}
function ToggleButton({
active,
disabled = false,
icon: Icon,
label,
onClick,
}: {
active: boolean;
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`flex items-center justify-between rounded-[6px] border px-2.5 py-2.5 text-[0.82rem] font-semibold transition active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-45 ${active
? "border-[rgb(232_207_170_/_0.56)] bg-[rgb(192_160_128_/_0.16)] text-white"
: "border-white/10 bg-white/[0.04] text-white/78 hover:border-white/24"
}`}
>
<span className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5 text-[var(--tour-gold-light)]" strokeWidth={1.9} />
{label}
</span>
<span className="h-2 w-2 rounded-full bg-current opacity-80" />
</button>
);
}