3349 lines
123 KiB
TypeScript
3349 lines
123 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";
|
|
import { lqip } from "@/app/tours/chua-thai-lac.lqip";
|
|
|
|
type SceneId = string;
|
|
|
|
type Hotspot = {
|
|
targetId: SceneId;
|
|
label: string;
|
|
yaw: number;
|
|
pitch: number;
|
|
rotation?: number;
|
|
nextYaw?: number;
|
|
scale?: number;
|
|
};
|
|
|
|
type InfoMarker = {
|
|
id: string;
|
|
title: string;
|
|
eyebrow: string;
|
|
yaw: number;
|
|
pitch: number;
|
|
rotation?: number;
|
|
content: string[];
|
|
iconType?: "envelope" | "landmark" | "info";
|
|
};
|
|
|
|
type TourScene = {
|
|
id: SceneId;
|
|
order: string;
|
|
title: string;
|
|
location: string;
|
|
image: string;
|
|
initialYaw: number;
|
|
initialPitch?: number;
|
|
mapPosition: {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
hotspots: Hotspot[];
|
|
infoMarkers?: InfoMarker[];
|
|
};
|
|
|
|
type Panel = "scenes" | "info" | "map" | "settings" | null;
|
|
type MapRoute = {
|
|
from: SceneId;
|
|
to: SceneId;
|
|
via?: { x: number; y: number }[];
|
|
};
|
|
|
|
type WelcomeCardConfig = {
|
|
image: string;
|
|
label: string;
|
|
rotate: string;
|
|
sceneId: SceneId;
|
|
};
|
|
|
|
type PracticalInfoItem = {
|
|
label: string;
|
|
value: string;
|
|
};
|
|
|
|
type TourPracticalInfo = {
|
|
intro: string;
|
|
items: PracticalInfoItem[];
|
|
notes: string[];
|
|
};
|
|
|
|
type TourConfig = {
|
|
id: string;
|
|
title: string;
|
|
subtitle: string;
|
|
welcomeTitle: string;
|
|
welcomeAccent: string;
|
|
welcomeSubtitle: string;
|
|
welcomeBackgroundImage: string;
|
|
welcomeCards: WelcomeCardConfig[];
|
|
backgroundAudio: string;
|
|
practicalInfo: TourPracticalInfo;
|
|
scenes: TourScene[];
|
|
sceneById: Map<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;
|
|
|
|
// Texture ảnh mờ (blur-up) tạo từ data-URI base64 — hiện gần như tức thì khi cảnh đang tải.
|
|
const lqipTextureCache = new Map<string, THREE.Texture>();
|
|
function loadLqipTexture(image: string) {
|
|
const data = lqip[image];
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
const cached = lqipTextureCache.get(image);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const texture = new THREE.TextureLoader().load(data);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
lqipTextureCache.set(image, texture);
|
|
return texture;
|
|
}
|
|
|
|
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>>();
|
|
|
|
const MAX_CACHED_PANORAMAS = 8; // đủ cho cảnh hiện tại + các cảnh lân cận
|
|
const pinnedPanoramaUrls = new Set<string>();
|
|
|
|
// Ghim các ảnh không được phép dispose (cảnh hiện tại + lân cận).
|
|
function setPinnedPanoramas(urls: string[]) {
|
|
pinnedPanoramaUrls.clear();
|
|
urls.forEach((url) => pinnedPanoramaUrls.add(url));
|
|
}
|
|
|
|
// Đánh dấu ảnh là mới-dùng-nhất (Map giữ thứ tự chèn) rồi dọn nếu vượt giới hạn.
|
|
function touchPanoramaCache(image: string, texture: THREE.Texture) {
|
|
panoramaTextureCache.delete(image);
|
|
panoramaTextureCache.set(image, texture);
|
|
evictPanoramaTextures();
|
|
}
|
|
|
|
function evictPanoramaTextures() {
|
|
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
|
|
return;
|
|
}
|
|
for (const [url, texture] of panoramaTextureCache) {
|
|
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
|
|
break;
|
|
}
|
|
if (pinnedPanoramaUrls.has(url)) {
|
|
continue; // không dispose ảnh đang ghim (đang hiển thị/lân cận)
|
|
}
|
|
panoramaTextureCache.delete(url);
|
|
texture.dispose();
|
|
}
|
|
}
|
|
|
|
function notifyPanoramaProgress(image: string, progress: number) {
|
|
panoramaTextureProgress.set(image, progress);
|
|
panoramaTextureProgressListeners.get(image)?.forEach((listener) => listener(progress));
|
|
}
|
|
|
|
function subscribePanoramaProgress(image: string, listener?: (progress: number) => void) {
|
|
if (!listener) {
|
|
return () => undefined;
|
|
}
|
|
|
|
const listeners = panoramaTextureProgressListeners.get(image) ?? new Set<(progress: number) => void>();
|
|
listeners.add(listener);
|
|
panoramaTextureProgressListeners.set(image, listeners);
|
|
listener(panoramaTextureProgress.get(image) ?? 0);
|
|
|
|
return () => {
|
|
listeners.delete(listener);
|
|
if (listeners.size === 0) {
|
|
panoramaTextureProgressListeners.delete(image);
|
|
}
|
|
};
|
|
}
|
|
|
|
function loadPanoramaTexture(image: string, onProgress?: (progress: number) => void) {
|
|
const cachedTexture = panoramaTextureCache.get(image);
|
|
|
|
if (cachedTexture) {
|
|
touchPanoramaCache(image, cachedTexture);
|
|
onProgress?.(100);
|
|
return Promise.resolve(cachedTexture);
|
|
}
|
|
|
|
const unsubscribe = subscribePanoramaProgress(image, onProgress);
|
|
const pendingTexture = panoramaTexturePromises.get(image);
|
|
|
|
if (pendingTexture) {
|
|
return pendingTexture.finally(unsubscribe);
|
|
}
|
|
|
|
const texturePromise = new Promise<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);
|
|
evictPanoramaTextures();
|
|
panoramaTexturePromises.delete(image);
|
|
notifyPanoramaProgress(image, 100);
|
|
resolve(texture);
|
|
},
|
|
(event) => {
|
|
if (event.lengthComputable && event.total > 0) {
|
|
notifyPanoramaProgress(image, Math.round((event.loaded / event.total) * 100));
|
|
}
|
|
},
|
|
() => {
|
|
panoramaTexturePromises.delete(image);
|
|
panoramaTextureProgress.delete(image);
|
|
reject(new Error("load-failed"));
|
|
},
|
|
);
|
|
});
|
|
|
|
panoramaTexturePromises.set(image, texturePromise);
|
|
return texturePromise.finally(unsubscribe);
|
|
}
|
|
|
|
function preloadSceneAndHotspots(scene: TourScene, sceneById: Map<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,
|
|
);
|
|
}
|
|
|
|
type IdleWindow = typeof window & {
|
|
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
|
|
};
|
|
|
|
// Lên lịch chạy lúc trình duyệt rảnh (fallback setTimeout) — dùng cho preload nền.
|
|
function scheduleIdle(cb: () => void) {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
const w = window as IdleWindow;
|
|
if (w.requestIdleCallback) {
|
|
w.requestIdleCallback(cb, { timeout: 2000 });
|
|
} else {
|
|
window.setTimeout(cb, 400);
|
|
}
|
|
}
|
|
|
|
export default function VirtualTour({
|
|
tourId = "chua-thai-lac",
|
|
}: {
|
|
tourId?: string;
|
|
}) {
|
|
const tourConfig = getTourConfig(tourId);
|
|
const [hasEntered, setHasEntered] = useState(false);
|
|
const [isEntering, setIsEntering] = useState(false);
|
|
const [initialSceneId, setInitialSceneId] = useState<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) => {
|
|
// Ghim ảnh đang dùng + các cảnh lân cận để LRU không dispose nhầm.
|
|
const neighborImages = scene.hotspots
|
|
.map((hotspot) => tourConfig.sceneById.get(hotspot.targetId)?.image)
|
|
.filter((image): image is string => Boolean(image));
|
|
setPinnedPanoramas([scene.image, ...neighborImages]);
|
|
|
|
const preloadImage = (image: string) => {
|
|
loadPanoramaTexture(image)
|
|
.then((texture) => warmPanoramaTexture(image, texture))
|
|
.catch(() => undefined);
|
|
};
|
|
|
|
// Cảnh hiện tại: tải ngay. Cảnh kề: lùi xuống lúc rảnh để không nghẽn khi vừa vào.
|
|
preloadImage(scene.image);
|
|
scheduleIdle(() => {
|
|
scene.hotspots.forEach((hotspot) => {
|
|
const targetScene = tourConfig.sceneById.get(hotspot.targetId);
|
|
|
|
if (targetScene) {
|
|
preloadImage(targetScene.image);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
[tourConfig, warmPanoramaTexture],
|
|
);
|
|
|
|
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);
|
|
needsRenderRef.current = true;
|
|
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);
|
|
if (!initialCachedTexture) {
|
|
const lqipTexture = loadLqipTexture(scene.image);
|
|
if (lqipTexture) {
|
|
material.map = lqipTexture;
|
|
material.opacity = 1;
|
|
material.needsUpdate = true;
|
|
needsRenderRef.current = true;
|
|
}
|
|
}
|
|
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>
|
|
);
|
|
}
|