diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index c6608f6..6461ae1 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -246,7 +246,14 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => { */ router.get('/hotspots/:scene_id', async (req, res) => { try { - const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id }); + const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id }) + .populate({ + path: 'target_scene_id', + select: 'name title assetId privacy shareToken', + populate: { path: 'assetId', select: '_id' } + }) + .lean(); + res.json(hotspots); } catch (error) { res.status(500).json({ message: error.message }); diff --git a/backend/uploads/processed_1780912760399_87af6fb5.JPG.jpg b/backend/uploads/processed_1780912760399_87af6fb5.JPG.jpg new file mode 100644 index 0000000..debf274 Binary files /dev/null and b/backend/uploads/processed_1780912760399_87af6fb5.JPG.jpg differ diff --git a/frontend/css/style.css b/frontend/css/style.css index 4ddceff..37d48f1 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -597,3 +597,95 @@ html, body { #logout-confirm-modal { z-index: 5500; /* Cao hơn Dashboard (4500) và Close Button (5000) */ } + +/* --- Pannellum Custom Hotspot (Callout Bubble) --- */ + +/* Container chính của hotspot do Pannellum quản lý */ +.pnlm-custom-hotspot { + width: 64px; + height: 64px; + /* Căn chỉnh để tâm bong bóng nằm đúng vị trí tọa độ pitch/yaw */ + margin-left: -32px; + margin-top: -32px; + z-index: 10; + /* Xóa bỏ icon sprite mặc định của Pannellum */ + background: none !important; +} + +.pnlm-custom-hotspot * { + box-sizing: border-box; +} + +/* Bong bóng callout */ +.hotspot-callout-bubble { + position: relative; + width: 100%; + height: 100%; + cursor: pointer; + transition: transform 0.2s ease-in-out; +} + +.hotspot-callout-bubble:hover { + transform: scale(1.15); + z-index: 1000; +} + +/* Khung chứa ảnh thumbnail tròn */ +.hotspot-thumb-frame { + width: 100%; + height: 100%; + border: 3px solid #ffffff; + border-radius: 50%; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + background-color: #333; + display: flex; + align-items: center; + justify-content: center; +} + +.hotspot-thumb-frame img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Đuôi nhọn của bong bóng trỏ xuống vị trí chính xác */ +.hotspot-callout-bubble::after { + content: ''; + position: absolute; + bottom: -10px; + left: 50%; + transform: translateX(-50%); + border-width: 10px 7px 0; + border-style: solid; + border-color: #ffffff transparent transparent; +} + +/* Tooltip tiêu đề xuất hiện khi hover */ +.hotspot-callout-title { + position: absolute; + top: -45px; /* Hiển thị phía trên bong bóng */ + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.85); + color: #fff; + padding: 5px 12px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + pointer-events: none; + z-index: 100; +} + +/* Hiệu ứng khi rê chuột qua hotspot */ +.hotspot-callout-bubble:hover .hotspot-callout-title { + opacity: 1; + visibility: visible; + top: -55px; /* Nhích lên một chút khi xuất hiện */ +} diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index 80b72f2..02b6e4c 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -745,6 +745,7 @@ async function openScene(sceneId, privacy, shareToken, force = false) { const scene = await sceneRes.json(); const hotspots = await hotspotsRes.json(); + console.log("DEBUG: Hotspots raw data from API:", hotspots); if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details'); diff --git a/frontend/js/viewer360.js b/frontend/js/viewer360.js index 7a3f1be..ef6c64a 100644 --- a/frontend/js/viewer360.js +++ b/frontend/js/viewer360.js @@ -3,6 +3,40 @@ let currentHotspots = []; let securityApplied = false; let currentSceneOwnerId = null; +/** + * Hàm render tùy chỉnh cho hotspot để hiển thị bong bóng callout kèm ảnh thumbnail. + * Thay thế icon mặc định của Pannellum bằng cấu trúc HTML mới. + */ +function renderCustomHotspot(hotSpotDiv, args) { + // DỌN DẸP: Xóa sạch các ký tự mặc định (+ - []) của Pannellum + hotSpotDiv.innerHTML = ''; + + hotSpotDiv.classList.add('pnlm-custom-hotspot'); + + // Tạo container chính cho bong bóng + const callout = document.createElement('div'); + callout.className = 'hotspot-callout-bubble'; + + // Tạo khung ảnh thumbnail (Luôn tạo để tránh bong bóng bị rỗng) + const imgWrapper = document.createElement('div'); + imgWrapper.className = 'hotspot-thumb-frame'; + + if (args.thumbUrl) { + const img = document.createElement('img'); + img.src = args.thumbUrl; + imgWrapper.appendChild(img); + } + callout.appendChild(imgWrapper); + + // Thêm nhãn tiêu đề (tên cảnh đích) + const title = document.createElement('div'); + title.className = 'hotspot-callout-title'; + title.innerText = args.title; + callout.appendChild(title); + + hotSpotDiv.appendChild(callout); +} + /** * Initializes and shows the Pannellum 360° panorama viewer with security overlays. * @param {string} imageUrl - Authorized URL to fetch the secure image stream @@ -22,19 +56,42 @@ function initPanoramaViewer(imageUrl, hotspots = [], ownerId = null) { } // Chuyển đổi dữ liệu hotspots từ DB sang định dạng Pannellum - const pannellumHotspots = hotspots.map(h => ({ - pitch: h.coordinates?.pitch || h.pitch, - yaw: h.coordinates?.yaw || h.yaw, - type: "info", - text: h.title || "Điểm điều hướng", - id: h._id, - clickHandlerFunc: () => { - if (h.target_scene_id || h.targetSceneId) { - // Gọi hàm openScene từ main_map.js - openScene(h.target_scene_id || h.targetSceneId); - } + const token = localStorage.getItem('jwt'); + const pannellumHotspots = hotspots.map(h => { + const target = h.target_scene_id; + let thumbUrl = ''; + + // Kiểm tra target phải là Object đã được populate thành công + if (target && typeof target === 'object' && target.assetId) { + const assetId = target.assetId._id || target.assetId; + thumbUrl = `/api/assets/view/${assetId}`; + + // Đính kèm token để vượt qua kiểm tra quyền truy cập của middleware + if (token) thumbUrl += `?token=${token}`; + else if (target.privacy === 'shared' && target.shareToken) thumbUrl += `?token=${target.shareToken}`; } - })); + + return { + pitch: h.coordinates?.pitch || h.pitch, + yaw: h.coordinates?.yaw || h.yaw, + type: "custom", + createTooltipFunc: renderCustomHotspot, + createTooltipArgs: { + title: h.title || target?.name || target?.title || "Điểm điều hướng", + thumbUrl: thumbUrl + }, + id: h._id, + clickHandlerFunc: () => { + if (target) { + const targetId = target._id || target; + const privacy = target.privacy || ''; + const shareToken = target.shareToken || ''; + // Chuyển cảnh với đầy đủ thông tin bảo mật + openScene(targetId, privacy, shareToken); + } + } + }; + }); // Initialize Pannellum Equirectangular viewer activeViewer = pannellum.viewer('panorama-viewer', {