My Vision v2.0 – Interactive Floor Plan Viewer
https://cdnjs.cloudflare.com/ajax/libs/pannellum/2.5.6/pannellum.js
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: ‘Segoe UI’, Tahoma, Geneva, Verdana, sans-serif;
}
body {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
header {
background-color: #333;
color: white;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-title {
font-size: 20px;
font-weight: bold;
display: flex;
align-items: center;
}
.app-title i {
margin-right: 10px;
}
.controls {
display: flex;
align-items: center;
}
button {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
margin-left: 10px;
transition: background-color 0.2s;
}
button:hover {
background-color: #45a049;
}
.mode-select {
display: flex;
background-color: #444;
border-radius: 4px;
overflow: hidden;
margin-right: 10px;
}
.mode-btn {
background-color: transparent;
border: none;
color: white;
padding: 8px 15px;
margin: 0;
border-radius: 0;
border-right: 1px solid #555;
}
.mode-btn:last-child {
border-right: none;
}
.mode-btn.active {
background-color: #4CAF50;
}
main {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 300px;
background-color: white;
border-right: 1px solid #ddd;
padding: 15px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.hotspot-list {
flex: 1;
overflow-y: auto;
}
.hotspot-item {
background-color: #f9f9f9;
border: 1px solid #ddd;
padding: 12px;
margin-bottom: 10px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.hotspot-item:hover {
background-color: #f0f0f0;
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.hotspot-item.selected {
border-color: #4CAF50;
background-color: #f0fff0;
}
.hotspot-name {
font-weight: bold;
margin-bottom: 5px;
display: flex;
align-items: center;
}
.hotspot-name i {
margin-right: 8px;
color: #666;
}
.canvas-area {
flex: 1;
background-color: #e9e9e9;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
position: relative;
}
.placeholder {
text-align: center;
padding: 20px;
}
.placeholder i {
font-size: 48px;
color: #ccc;
margin-bottom: 10px;
}
.placeholder button {
margin-top: 15px;
}
.floor-plan-container {
position: relative;
display: none;
max-width: 100%;
max-height: 100%;
}
.floor-plan {
max-width: 100%;
max-height: 90vh;
display: block;
border: 1px solid #ddd;
background-color: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.hotspot-marker {
position: absolute;
width: 24px;
height: 24px;
background-color: rgba(255, 0, 0, 0.8);
border: 2px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
transition: all 0.2s;
display: flex;
justify-content: center;
align-items: center;
color: white;
font-size: 12px;
font-weight: bold;
z-index: 5;
user-select: none;
}
.hotspot-marker:hover {
transform: translate(-50%, -50%) scale(1.2);
z-index: 10;
}
.hotspot-marker.dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.3);
z-index: 15;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
}
.hotspot-marker.draggable {
cursor: grab;
}
.hotspot-marker.draggable:hover {
background-color: rgba(255, 100, 100, 0.9);
}
.hotspot-label {
position: absolute;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
transform: translate(15px, -50%);
z-index: 5;
}
.hotspot-form {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: white;
padding: 20px;
border-top: 1px solid #ddd;
box-shadow: 0 -5px 15px rgba(0,0,0,0.1);
display: none;
z-index: 100;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.form-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
grid-column: 1 / -1;
}
.media-viewer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.9);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.media-container {
position: relative;
width: 90%;
height: 90%;
}
.media-container img,
.media-container video {
max-width: 100%;
max-height: 90vh;
display: block;
}
.close-btn {
position: absolute;
top: -40px;
right: 0;
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
}
#panorama-viewer {
width: 100%;
height: 100%;
display: none;
}
#video360-viewer {
width: 100%;
height: 100%;
display: none;
}
.file-format-info {
font-size: 12px;
color: #666;
margin-top: 3px;
}
.media-preview {
grid-column: 1 / -1;
height: 200px;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.media-preview img,
.media-preview video {
max-width: 100%;
max-height: 100%;
}
.media-preview .placeholder-icon {
font-size: 48px;
color: #ccc;
}
.toast {
position: fixed;
top: 20px;
right: 20px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 4px;
z-index: 2000;
display: none;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
.show-labels-container {
display: flex;
align-items: center;
margin-top: 10px;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
margin-left: 10px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: “”;
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: #4CAF50;
}
input:checked + .toggle-slider:before {
transform: translateX(26px);
}
.options-panel {
margin-top: auto;
padding-top: 15px;
border-top: 1px solid #eee;
}
.coordinates-info {
background-color: #f0f8ff;
padding: 8px;
border-radius: 4px;
font-size: 11px;
color: #666;
margin-top: 5px;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.sidebar {
width: 250px;
}
.form-grid {
grid-template-columns: 1fr;
}
}
Create
Edit
View
New
Replace Plan
Save
Load
No floor plan loaded
Load a floor plan to start
Load Floor Plan
Add Hotspot
×
Image
Video
360° Photo
360° Video
console.log(‘My Vision v2.0 Loading…’);
// DOM Elements
const createModeBtn = document.getElementById(‘create-mode’);
const editModeBtn = document.getElementById(‘edit-mode’);
const viewModeBtn = document.getElementById(‘view-mode’);
const newBtn = document.getElementById(‘new-btn’);
const saveBtn = document.getElementById(‘save-btn’);
const loadBtn = document.getElementById(‘load-btn’);
const loadFloorPlanBtn = document.getElementById(‘load-floor-plan’);
const replaceFloorPlanBtn = document.getElementById(‘replace-floor-plan’);
const placeholder = document.getElementById(‘placeholder’);
const floorPlanContainer = document.getElementById(‘floor-plan-container’);
const floorPlan = document.getElementById(‘floor-plan’);
const hotspotList = document.getElementById(‘hotspot-list’);
const hotspotCount = document.getElementById(‘hotspot-count’);
const hotspotForm = document.getElementById(‘hotspot-form’);
const formTitle = document.getElementById(‘form-title’);
const formClose = document.getElementById(‘form-close’);
const hotspotNameInput = document.getElementById(‘hotspot-name’);
const hotspotTypeSelect = document.getElementById(‘hotspot-type’);
const formatInfo = document.getElementById(‘format-info’);
const hotspotMediaInput = document.getElementById(‘hotspot-media’);
const hotspotXInput = document.getElementById(‘hotspot-x’);
const hotspotYInput = document.getElementById(‘hotspot-y’);
const mediaPreview = document.getElementById(‘media-preview’);
const saveHotspotBtn = document.getElementById(‘save-hotspot’);
const cancelHotspotBtn = document.getElementById(‘cancel-hotspot’);
const mediaViewer = document.getElementById(‘media-viewer’);
const imageViewer = document.getElementById(‘image-viewer’);
const videoViewer = document.getElementById(‘video-viewer’);
const panoramaViewer = document.getElementById(‘panorama-viewer’);
const video360Viewer = document.getElementById(‘video360-viewer’);
const closeViewerBtn = document.getElementById(‘close-viewer’);
const toast = document.getElementById(‘toast’);
const showLabelsToggle = document.getElementById(‘show-labels’);
// State variables
let currentMode = ‘create’;
let floorplanImage = null;
let hotspots = [];
let currentHotspot = null;
let panoramaInstance = null;
let video360Instance = null;
let projectName = ‘New Project’;
let showLabels = false;
let originalImageWidth = 0;
let originalImageHeight = 0;
// Drag & Drop variables
let isDragging = false;
let dragHotspot = null;
let dragOffset = { x: 0, y: 0 };
console.log(‘Variables initialized’);
// Convert pixel coordinates to percentage
function pixelToPercent(x, y, imgWidth, imgHeight) {
const xPercent = (x / imgWidth) * 100;
const yPercent = (y / imgHeight) * 100;
console.log(`Converting pixels (${x}, ${y}) to percent (${xPercent.toFixed(2)}%, ${yPercent.toFixed(2)}%)`);
return { x: xPercent, y: yPercent };
}
// Convert percentage coordinates to pixel
function percentToPixel(xPercent, yPercent, imgWidth, imgHeight) {
const x = (xPercent / 100) * imgWidth;
const y = (yPercent / 100) * imgHeight;
console.log(`Converting percent (${xPercent}%, ${yPercent}%) to pixels (${x}, ${y})`);
return { x: x, y: y };
}
// Get current image dimensions
function getCurrentImageDimensions() {
const rect = floorPlan.getBoundingClientRect();
return {
width: rect.width,
height: rect.height
};
}
// Update hotspot positions when window resizes
window.addEventListener(‘resize’, function() {
console.log(‘Window resized, updating hotspot positions’);
updateHotspotMarkers();
});
// Function to set the mode
function setMode(mode) {
console.log(‘Setting mode to:’, mode);
currentMode = mode;
// Update UI for mode buttons
createModeBtn.classList.toggle(‘active’, mode === ‘create’);
editModeBtn.classList.toggle(‘active’, mode === ‘edit’);
viewModeBtn.classList.toggle(‘active’, mode === ‘view’);
// Show/hide replace button
if (floorplanImage) {
replaceFloorPlanBtn.style.display = (mode === ‘create’ || mode === ‘edit’) ? ‘inline-block’ : ‘none’;
}
// Handle cursor and interactions
if (floorPlan) {
floorPlan.style.cursor = mode === ‘view’ ? ‘default’ : ‘crosshair’;
}
// Hide form in view mode
if (mode === ‘view’) {
hideHotspotForm();
}
// Update hotspot list to reflect the mode
renderHotspotList();
// Update markers
updateHotspotMarkers();
showToast(`Mode: ${mode === ‘create’ ? ‘Create’ : mode === ‘edit’ ? ‘Edit’ : ‘View’}`);
}
// New project
function newProject() {
console.log(‘Creating new project’);
if (confirm(‘Create a new project? Unsaved data will be lost.’)) {
projectName = ‘New Project’;
floorplanImage = null;
hotspots = [];
currentHotspot = null;
originalImageWidth = 0;
originalImageHeight = 0;
// Reset UI
placeholder.style.display = ‘flex’;
floorPlanContainer.style.display = ‘none’;
floorPlan.src = ”;
renderHotspotList();
showToast(‘New project created’);
}
}
// Update info about media type
function updateMediaTypeInfo() {
const type = hotspotTypeSelect.value;
switch(type) {
case ‘image’:
formatInfo.textContent = ‘Supported formats: JPEG, PNG, GIF, WebP’;
hotspotMediaInput.accept = ‘image/jpeg,image/png,image/gif,image/webp’;
break;
case ‘video’:
formatInfo.textContent = ‘Supported formats: MP4, MOV, M4V, WebM’;
hotspotMediaInput.accept = ”;
break;
case ‘panorama’:
formatInfo.textContent = ‘Supported formats: Equirectangular images (2:1) JPEG, PNG’;
hotspotMediaInput.accept = ‘image/jpeg,image/png’;
break;
case ‘video360’:
formatInfo.textContent = ‘Supported formats: 360° videos MP4, WebM’;
hotspotMediaInput.accept = ”;
break;
}
}
// Preview selected media
function previewSelectedMedia() {
const file = hotspotMediaInput.files[0];
if (!file) {
mediaPreview.innerHTML = ‘
‘;
return;
}
const type = hotspotTypeSelect.value;
const fileExtension = file.name.split(‘.’).pop().toLowerCase();
const isImage = file.type.startsWith(‘image/’);
const isVideo = file.type.startsWith(‘video/’) || [‘mov’, ‘mp4’, ‘webm’, ‘m4v’].includes(fileExtension);
if ((type === ‘image’ || type === ‘panorama’) && !isImage) {
showToast(‘Invalid file type for this hotspot type’, ‘error’);
hotspotMediaInput.value = ”;
return;
}
if ((type === ‘video’ || type === ‘video360’) && !isVideo) {
showToast(‘Invalid file type for this hotspot type’, ‘error’);
hotspotMediaInput.value = ”;
return;
}
const reader = new FileReader();
reader.onload = function(e) {
if (isImage) {
mediaPreview.innerHTML = ``;
} else if (isVideo) {
const blob = new Blob([new Uint8Array(e.target.result)], { type: ‘video/mp4’ });
const blobUrl = URL.createObjectURL(blob);
mediaPreview.innerHTML = ``;
}
};
if (isVideo) {
reader.readAsArrayBuffer(file);
} else {
reader.readAsDataURL(file);
}
}
// Show/hide hotspot labels
function toggleHotspotLabels() {
showLabels = showLabelsToggle.checked;
updateHotspotMarkers();
showToast(`Hotspot names ${showLabels ? ‘visible’ : ‘hidden’}`);
}
// Load floor plan
function loadFloorPlan() {
console.log(‘Loading floor plan’);
const input = document.createElement(‘input’);
input.type = ‘file’;
input.accept = ‘image/jpeg,image/png,image/gif,image/webp’;
input.onchange = function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
// Store original image dimensions
originalImageWidth = img.naturalWidth;
originalImageHeight = img.naturalHeight;
console.log(`Original image dimensions: ${originalImageWidth}x${originalImageHeight}`);
// Save image reference
floorplanImage = {
element: img,
src: e.target.result,
originalWidth: originalImageWidth,
originalHeight: originalImageHeight
};
// Set the image source and show it
floorPlan.src = e.target.result;
// Show the floor plan and hide placeholder
placeholder.style.display = ‘none’;
floorPlanContainer.style.display = ‘block’;
// Show replace button if in create/edit mode
if (currentMode === ‘create’ || currentMode === ‘edit’) {
replaceFloorPlanBtn.style.display = ‘inline-block’;
}
// Set cursor based on the mode
floorPlan.style.cursor = currentMode === ‘view’ ? ‘default’ : ‘crosshair’;
showToast(‘Floor plan loaded successfully’);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
};
input.click();
}
// Replace floor plan
function replaceFloorPlan() {
console.log(‘Replacing floor plan’);
if (!confirm(‘Replace the current floor plan? This will keep all hotspots but they may need repositioning.’)) {
return;
}
const input = document.createElement(‘input’);
input.type = ‘file’;
input.accept = ‘image/jpeg,image/png,image/gif,image/webp’;
input.onchange = function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
// Store new original image dimensions
const newImageWidth = img.naturalWidth;
const newImageHeight = img.naturalHeight;
console.log(`New image dimensions: ${newImageWidth}x${newImageHeight}`);
console.log(`Previous dimensions: ${originalImageWidth}x${originalImageHeight}`);
// Update image reference
floorplanImage = {
element: img,
src: e.target.result,
originalWidth: newImageWidth,
originalHeight: newImageHeight
};
// Update original dimensions
originalImageWidth = newImageWidth;
originalImageHeight = newImageHeight;
// Set the new image
floorPlan.src = e.target.result;
// Update hotspot markers with new image
updateHotspotMarkers();
showToast(‘Floor plan replaced successfully’);
// Show warning about repositioning if there are hotspots
if (hotspots.length > 0) {
setTimeout(() => {
showToast(‘Check hotspot positions – you may need to reposition them’, ‘info’);
}, 2000);
}
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
};
input.click();
}
function handleFloorPlanClick(e) {
console.log(‘Floor plan clicked, mode:’, currentMode);
if (currentMode === ‘view’) return;
// Get coordinates relative to the image
const rect = e.target.getBoundingClientRect();
const x = e.clientX – rect.left;
const y = e.clientY – rect.top;
// Convert to percentage coordinates
const currentDimensions = getCurrentImageDimensions();
const percentCoords = pixelToPercent(x, y, currentDimensions.width, currentDimensions.height);
// Check if clicked on an existing hotspot
const existingHotspot = findHotspotAt(percentCoords.x, percentCoords.y);
if (existingHotspot) {
currentHotspot = existingHotspot;
if (currentMode === ‘view’) {
showMediaViewer(existingHotspot);
} else {
showHotspotForm(existingHotspot);
}
return;
}
// Create new hotspot (only in create mode)
if (currentMode === ‘create’) {
const newHotspot = {
id: Date.now().toString(),
name: `Hotspot ${hotspots.length + 1}`,
type: ‘image’,
x: percentCoords.x,
y: percentCoords.y,
mediaData: null
};
currentHotspot = newHotspot;
showHotspotForm(newHotspot);
}
}
// Drag & Drop functions
function startDrag(e, hotspot, marker) {
if (currentMode === ‘view’) return;
e.stopPropagation();
e.preventDefault();
isDragging = true;
dragHotspot = hotspot;
// Calculate offset from marker center
const rect = marker.getBoundingClientRect();
const floorRect = floorPlan.getBoundingClientRect();
dragOffset.x = e.clientX – rect.left – rect.width / 2;
dragOffset.y = e.clientY – rect.top – rect.height / 2;
marker.classList.add(‘dragging’);
floorPlan.style.cursor = ‘grabbing’;
console.log(‘Started dragging hotspot:’, hotspot.name);
// Add temporary event listeners
document.addEventListener(‘mousemove’, dragMove);
document.addEventListener(‘mouseup’, endDrag);
}
function dragMove(e) {
if (!isDragging || !dragHotspot) return;
e.preventDefault();
// Get new position relative to floor plan
const floorRect = floorPlan.getBoundingClientRect();
const x = e.clientX – floorRect.left – dragOffset.x;
const y = e.clientY – floorRect.top – dragOffset.y;
// Convert to percentage coordinates
const currentDimensions = getCurrentImageDimensions();
const percentCoords = pixelToPercent(x, y, currentDimensions.width, currentDimensions.height);
// Constrain to image bounds
percentCoords.x = Math.max(0, Math.min(100, percentCoords.x));
percentCoords.y = Math.max(0, Math.min(100, percentCoords.y));
// Update hotspot coordinates
dragHotspot.x = percentCoords.x;
dragHotspot.y = percentCoords.y;
// Update marker position immediately
const marker = document.querySelector(`.hotspot-marker[data-id=”${dragHotspot.id}”]`);
if (marker) {
const newPixelCoords = percentToPixel(percentCoords.x, percentCoords.y, currentDimensions.width, currentDimensions.height);
marker.style.left = `${newPixelCoords.x}px`;
marker.style.top = `${newPixelCoords.y}px`;
// Update label position if visible
const label = document.querySelector(`.hotspot-label[data-id=”${dragHotspot.id}”]`);
if (label) {
label.style.left = `${newPixelCoords.x}px`;
label.style.top = `${newPixelCoords.y}px`;
}
}
}
function endDrag(e) {
if (!isDragging) return;
console.log(‘Ended dragging hotspot:’, dragHotspot ? dragHotspot.name : ‘unknown’);
// Clean up
const marker = document.querySelector(`.hotspot-marker[data-id=”${dragHotspot.id}”]`);
if (marker) {
marker.classList.remove(‘dragging’);
}
floorPlan.style.cursor = currentMode === ‘view’ ? ‘default’ : ‘crosshair’;
// Update hotspot in array
if (dragHotspot) {
const index = hotspots.findIndex(h => h.id === dragHotspot.id);
if (index !== -1) {
hotspots[index] = dragHotspot;
}
// Update the list display
renderHotspotList();
showToast(`Hotspot “${dragHotspot.name}” repositioned`);
}
// Reset drag state
isDragging = false;
dragHotspot = null;
dragOffset = { x: 0, y: 0 };
// Remove temporary event listeners
document.removeEventListener(‘mousemove’, dragMove);
document.removeEventListener(‘mouseup’, endDrag);
}
function findHotspotAt(xPercent, yPercent) {
const threshold = 2; // percentage threshold for clicking
return hotspots.find(hotspot => {
const dx = Math.abs(hotspot.x – xPercent);
const dy = Math.abs(hotspot.y – yPercent);
return dx < threshold && dy h.id === hotspot.id);
formTitle.textContent = isNew ? ‘Add Hotspot’ : ‘Edit Hotspot’;
// Populate the form
hotspotNameInput.value = hotspot.name || ”;
hotspotTypeSelect.value = hotspot.type || ‘image’;
hotspotXInput.value = hotspot.x.toFixed(2);
hotspotYInput.value = hotspot.y.toFixed(2);
hotspotMediaInput.value = ”;
updateMediaTypeInfo();
// Show preview if available
if (hotspot.mediaData) {
if (hotspot.type === ‘image’ || hotspot.type === ‘panorama’) {
mediaPreview.innerHTML = ``;
} else {
mediaPreview.innerHTML = ``;
}
} else {
mediaPreview.innerHTML = ‘
‘;
}
hotspotForm.style.display = ‘block’;
}
// Hide hotspot form
function hideHotspotForm() {
console.log(‘Hiding hotspot form’);
hotspotForm.style.display = ‘none’;
currentHotspot = null;
}
// Save hotspot
function saveHotspot() {
console.log(‘Saving hotspot’);
if (!currentHotspot) return;
const name = hotspotNameInput.value.trim();
if (!name) {
showToast(‘Please enter a name for the hotspot’, ‘error’);
return;
}
// Update basic data
currentHotspot.name = name;
currentHotspot.type = hotspotTypeSelect.value;
currentHotspot.x = parseFloat(hotspotXInput.value);
currentHotspot.y = parseFloat(hotspotYInput.value);
// Handle media file
const mediaFile = hotspotMediaInput.files[0];
if (mediaFile) {
const fileExtension = mediaFile.name.split(‘.’).pop().toLowerCase();
const isImage = mediaFile.type.startsWith(‘image/’);
const isVideo = mediaFile.type.startsWith(‘video/’) || [‘mov’, ‘mp4’, ‘webm’, ‘m4v’].includes(fileExtension);
if ((currentHotspot.type === ‘image’ || currentHotspot.type === ‘panorama’) && !isImage) {
showToast(‘Please select an image file’, ‘error’);
return;
}
if ((currentHotspot.type === ‘video’ || currentHotspot.type === ‘video360’) && !isVideo) {
showToast(‘Please select a video file’, ‘error’);
return;
}
const reader = new FileReader();
reader.onload = function(e) {
currentHotspot.mediaData = e.target.result;
finalizeSaveHotspot();
};
reader.readAsDataURL(mediaFile);
} else {
finalizeSaveHotspot();
}
}
// Finalize hotspot save
function finalizeSaveHotspot() {
console.log(‘Finalizing hotspot save’);
const index = hotspots.findIndex(h => h.id === currentHotspot.id);
if (index === -1) {
hotspots.push(currentHotspot);
} else {
hotspots[index] = currentHotspot;
}
renderHotspotList();
updateHotspotMarkers();
hideHotspotForm();
showToast(‘Hotspot saved successfully’);
}
// Update hotspot list
function renderHotspotList() {
console.log(‘Rendering hotspot list, count:’, hotspots.length);
hotspotCount.textContent = hotspots.length;
if (hotspots.length === 0) {
hotspotList.innerHTML = ‘
No hotspots available
‘;
return;
}
hotspotList.innerHTML = ”;
hotspots.forEach(hotspot => {
const item = document.createElement(‘div’);
item.className = ‘hotspot-item’;
item.dataset.id = hotspot.id;
let typeIcon = ‘fa-image’;
let typeLabel = ‘Image’;
if (hotspot.type === ‘video’) {
typeIcon = ‘fa-video’;
typeLabel = ‘Video’;
}
else if (hotspot.type === ‘panorama’) {
typeIcon = ‘fa-globe’;
typeLabel = ‘360° Photo’;
}
else if (hotspot.type === ‘video360’) {
typeIcon = ‘fa-film’;
typeLabel = ‘360° Video’;
}
let html = `
${hotspot.name}
`;
if (currentMode !== ‘view’) {
html += `
Edit
Delete
`;
}
item.innerHTML = html;
item.addEventListener(‘click’, () => {
if (currentMode === ‘view’ && hotspot.mediaData) {
showMediaViewer(hotspot);
} else if (currentMode !== ‘view’) {
currentHotspot = hotspot;
showHotspotForm(hotspot);
}
});
hotspotList.appendChild(item);
});
// Add event listeners for edit/delete buttons
if (currentMode !== ‘view’) {
document.querySelectorAll(‘.edit-btn’).forEach(btn => {
btn.addEventListener(‘click’, (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const hotspot = hotspots.find(h => h.id === id);
if (hotspot) {
currentHotspot = hotspot;
showHotspotForm(hotspot);
}
});
});
document.querySelectorAll(‘.delete-btn’).forEach(btn => {
btn.addEventListener(‘click’, (e) => {
e.stopPropagation();
const id = btn.dataset.id;
if (confirm(‘Are you sure you want to delete this hotspot?’)) {
deleteHotspot(id);
}
});
});
}
}
// Delete hotspot
function deleteHotspot(id) {
console.log(‘Deleting hotspot:’, id);
const index = hotspots.findIndex(h => h.id === id);
if (index !== -1) {
hotspots.splice(index, 1);
renderHotspotList();
updateHotspotMarkers();
showToast(‘Hotspot deleted’);
}
}
// Update hotspot markers on the floor plan
function updateHotspotMarkers() {
console.log(‘Updating hotspot markers’);
// Remove existing markers
document.querySelectorAll(‘.hotspot-marker, .hotspot-label’).forEach(marker => {
marker.remove();
});
if (!floorplanImage || !floorPlan) return;
const currentDimensions = getCurrentImageDimensions();
hotspots.forEach(hotspot => {
// Convert percentage coordinates to current pixel coordinates
const pixelCoords = percentToPixel(hotspot.x, hotspot.y, currentDimensions.width, currentDimensions.height);
// Create marker
const marker = document.createElement(‘div’);
marker.className = ‘hotspot-marker’;
marker.style.left = `${pixelCoords.x}px`;
marker.style.top = `${pixelCoords.y}px`;
marker.dataset.id = hotspot.id;
// Make draggable in create/edit modes
if (currentMode === ‘create’ || currentMode === ‘edit’) {
marker.classList.add(‘draggable’);
marker.addEventListener(‘mousedown’, (e) => startDrag(e, hotspot, marker));
}
let icon = ”;
if (hotspot.type === ‘image’) icon = ‘I’;
if (hotspot.type === ‘video’) icon = ‘V’;
if (hotspot.type === ‘panorama’) icon = ‘360’;
if (hotspot.type === ‘video360’) icon = ‘V360’;
marker.textContent = icon;
marker.addEventListener(‘click’, (e) => {
e.stopPropagation();
if (currentMode === ‘view’) {
if (hotspot.mediaData) {
showMediaViewer(hotspot);
}
} else {
currentHotspot = hotspot;
showHotspotForm(hotspot);
}
});
floorPlanContainer.appendChild(marker);
// Create label if enabled
if (showLabels) {
const label = document.createElement(‘div’);
label.className = ‘hotspot-label’;
label.style.left = `${pixelCoords.x}px`;
label.style.top = `${pixelCoords.y}px`;
label.dataset.id = hotspot.id;
label.textContent = hotspot.name;
floorPlanContainer.appendChild(label);
}
});
}
// Show media viewer
function showMediaViewer(hotspot) {
if (!hotspot.mediaData) return;
// Reset all viewers
imageViewer.style.display = ‘none’;
videoViewer.style.display = ‘none’;
panoramaViewer.style.display = ‘none’;
video360Viewer.style.display = ‘none’;
// Destroy previous instances
if (panoramaInstance) {
panoramaInstance.destroy();
panoramaInstance = null;
}
switch(hotspot.type) {
case ‘image’:
imageViewer.src = hotspot.mediaData;
imageViewer.style.display = ‘block’;
break;
case ‘video’:
videoViewer.src = hotspot.mediaData;
videoViewer.style.display = ‘block’;
break;
case ‘panorama’:
panoramaViewer.style.display = ‘block’;
try {
panoramaInstance = pannellum.viewer(‘panorama-viewer’, {
type: ‘equirectangular’,
panorama: hotspot.mediaData,
autoLoad: true,
compass: true,
title: hotspot.name,
showFullscreenCtrl: true,
showControls: true
});
} catch (error) {
console.error(‘Error loading panorama:’, error);
showToast(‘Error loading panorama’, ‘error’);
imageViewer.src = hotspot.mediaData;
imageViewer.style.display = ‘block’;
panoramaViewer.style.display = ‘none’;
}
break;
case ‘video360’:
video360Viewer.style.display = ‘block’;
try {
video360Viewer.innerHTML = `
`;
const video = document.getElementById(‘video360’);
video.addEventListener(‘canplay’, () => {
try {
panoramaInstance = pannellum.viewer(‘video360-viewer’, {
type: ‘equirectangular’,
panorama: video,
autoLoad: true,
compass: true,
title: hotspot.name,
showFullscreenCtrl: true,
showControls: true,
dynamic: true
});
} catch (err) {
console.error(‘Error creating 360 video viewer:’, err);
videoViewer.src = hotspot.mediaData;
videoViewer.style.display = ‘block’;
video360Viewer.style.display = ‘none’;
}
});
video.onerror = function() {
console.error(‘360 video error’);
videoViewer.src = hotspot.mediaData;
videoViewer.style.display = ‘block’;
video360Viewer.style.display = ‘none’;
};
video.load();
} catch (error) {
console.error(‘Error setting up 360 video:’, error);
videoViewer.src = hotspot.mediaData;
videoViewer.style.display = ‘block’;
video360Viewer.style.display = ‘none’;
}
break;
}
mediaViewer.style.display = ‘flex’;
}
// Close media viewer
function closeMediaViewer() {
mediaViewer.style.display = ‘none’;
videoViewer.pause();
if (panoramaInstance) {
panoramaInstance.destroy();
panoramaInstance = null;
}
}
// Save project
function saveProject() {
console.log(‘Saving project’);
if (!floorplanImage) {
showToast(‘Load a floor plan first!’, ‘error’);
return;
}
const name = prompt(‘Project name:’, projectName);
if (!name) return;
projectName = name;
const projectData = {
name: projectName,
createdAt: new Date().toISOString(),
floorplanData: floorplanImage.src,
hotspots: hotspots,
showLabels: showLabels,
version: ‘2.0’,
originalImageWidth: originalImageWidth,
originalImageHeight: originalImageHeight
};
const jsonData = JSON.stringify(projectData);
const blob = new Blob([jsonData], { type: ‘application/json’ });
const url = URL.createObjectURL(blob);
const a = document.createElement(‘a’);
a.href = url;
a.download = `${projectName.replace(/[^a-z0-9]/gi, ‘_’).toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
showToast(‘Project saved successfully’);
}
// Load project
function loadProject() {
console.log(‘Loading project’);
const input = document.createElement(‘input’);
input.type = ‘file’;
input.accept = ‘.json’;
input.onchange = function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
projectName = data.name || ‘Unnamed Project’;
hotspots = data.hotspots || [];
// Load original image dimensions if available
if (data.originalImageWidth && data.originalImageHeight) {
originalImageWidth = data.originalImageWidth;
originalImageHeight = data.originalImageHeight;
}
// Handle legacy projects (v1.0) that used pixel coordinates
if (!data.version || data.version === ‘1.0’) {
showToast(‘Converting legacy project to v2.0 format…’, ‘info’);
convertLegacyHotspots();
}
if (data.showLabels !== undefined) {
showLabels = data.showLabels;
showLabelsToggle.checked = showLabels;
}
if (data.floorplanData) {
const img = new Image();
img.onload = function() {
// Update original dimensions if not present
if (!originalImageWidth || !originalImageHeight) {
originalImageWidth = img.naturalWidth;
originalImageHeight = img.naturalHeight;
}
floorplanImage = {
element: img,
src: data.floorplanData,
originalWidth: originalImageWidth,
originalHeight: originalImageHeight
};
floorPlan.src = data.floorplanData;
placeholder.style.display = ‘none’;
floorPlanContainer.style.display = ‘block’;
// Show replace button if in create/edit mode
if (currentMode === ‘create’ || currentMode === ‘edit’) {
replaceFloorPlanBtn.style.display = ‘inline-block’;
}
floorPlan.style.cursor = currentMode === ‘view’ ? ‘default’ : ‘crosshair’;
updateHotspotMarkers();
renderHotspotList();
};
img.src = data.floorplanData;
}
showToast(`Project “${projectName}” loaded successfully`);
} catch (error) {
console.error(‘Error loading project:’, error);
showToast(‘Error loading project’, ‘error’);
}
};
reader.readAsText(file);
};
input.click();
}
// Convert legacy pixel coordinates to percentage
function convertLegacyHotspots() {
if (!originalImageWidth || !originalImageHeight) {
showToast(‘Cannot convert legacy project: missing original dimensions’, ‘error’);
return;
}
hotspots.forEach(hotspot => {
// If coordinates are larger than 100, they’re probably pixels
if (hotspot.x > 100 || hotspot.y > 100) {
const percentCoords = pixelToPercent(hotspot.x, hotspot.y, originalImageWidth, originalImageHeight);
hotspot.x = percentCoords.x;
hotspot.y = percentCoords.y;
console.log(`Converted hotspot ${hotspot.name} from pixel to percentage coordinates`);
}
});
}
// Function to show toast messages
function showToast(message, type = ‘info’) {
console.log(‘Toast:’, message, type);
toast.textContent = message;
toast.style.backgroundColor = type === ‘error’ ? ‘rgba(220, 53, 69, 0.9)’ : ‘rgba(0, 0, 0, 0.7)’;
toast.style.display = ‘block’;
setTimeout(() => {
toast.style.display = ‘none’;
}, 3000);
}
// Event listeners
console.log(‘Setting up event listeners…’);
createModeBtn.addEventListener(‘click’, () => setMode(‘create’));
editModeBtn.addEventListener(‘click’, () => setMode(‘edit’));
viewModeBtn.addEventListener(‘click’, () => setMode(‘view’));
newBtn.addEventListener(‘click’, newProject);
saveBtn.addEventListener(‘click’, saveProject);
loadBtn.addEventListener(‘click’, loadProject);
loadFloorPlanBtn.addEventListener(‘click’, loadFloorPlan);
replaceFloorPlanBtn.addEventListener(‘click’, replaceFloorPlan);
saveHotspotBtn.addEventListener(‘click’, saveHotspot);
cancelHotspotBtn.addEventListener(‘click’, hideHotspotForm);
formClose.addEventListener(‘click’, hideHotspotForm);
closeViewerBtn.addEventListener(‘click’, closeMediaViewer);
showLabelsToggle.addEventListener(‘change’, toggleHotspotLabels);
hotspotTypeSelect.addEventListener(‘change’, updateMediaTypeInfo);
hotspotMediaInput.addEventListener(‘change’, previewSelectedMedia);
// Add floor plan click listener
floorPlan.addEventListener(‘click’, handleFloorPlanClick);
// Initialize
updateMediaTypeInfo();
console.log(‘My Vision v2.0 – Ready!’);
showToast(‘My Vision v2.0 loaded successfully’);
