Skip to content

Hybrid Navigation System

Feature Spec: Floor Plans + Interactive Maps Status: Design Complete Priority: HIGH (Feature Parity + Differentiation)


Overview

The hybrid navigation system allows creators to add spatial navigation to their experiences using either: 1. Floor Plans - Upload an image and place pins (buildings, venues, campuses) 2. Interactive Maps - Geographic locations on a world/terrain map (cities, countries, global tours)

Users can choose the navigation type that fits their content.


Type Definitions

New Types to Add to src/types/config.ts

// ============================================================================
// NAVIGATION TYPES
// ============================================================================

/**
 * Geographic coordinates (latitude/longitude)
 */
export interface GeoCoordinates {
  lat: number;   // Latitude (-90 to 90)
  lng: number;   // Longitude (-180 to 180)
}

/**
 * 2D position on a floor plan image (percentage-based)
 */
export interface FloorPlanPosition {
  x: number;     // 0-1 (0 = left edge, 1 = right edge)
  y: number;     // 0-1 (0 = top edge, 1 = bottom edge)
}

/**
 * A marker/pin on the navigation overlay
 */
export interface NavigationMarker {
  stageId: string;              // Links to ExperienceStage.id
  label?: string;               // Override stage name for marker label

  // Position (one of these is required based on navigation type)
  floorPlanPosition?: FloorPlanPosition;  // For floor plans
  geoPosition?: GeoCoordinates;            // For maps

  // Optional customization
  icon?: 'default' | 'star' | 'info' | 'camera' | 'video' | 'audio' | 'custom';
  customIconUrl?: string;       // URL to custom marker icon
  color?: string;               // Marker color (hex)
  initialViewDirection?: number; // Camera direction when entering (degrees, 0 = north)
}

/**
 * Floor plan configuration
 */
export interface FloorPlanConfig {
  imageUrl: string;             // PNG, JPG, or SVG of floor plan

  // Optional: Real-world dimensions (for scale reference)
  realWorldWidth?: number;      // Width in meters
  realWorldHeight?: number;     // Height in meters

  // Multi-floor support
  floor?: number;               // Floor number (for multi-floor buildings)
  floorLabel?: string;          // "Ground Floor", "Level 2", etc.
}

/**
 * Interactive map configuration
 */
export interface MapConfig {
  style: 'streets' | 'satellite' | 'terrain' | 'dark' | 'light';

  // Map bounds (optional - auto-fits to markers if not specified)
  bounds?: {
    north: number;  // Max latitude
    south: number;  // Min latitude
    east: number;   // Max longitude
    west: number;   // Min longitude
  };

  // Default view
  defaultCenter?: GeoCoordinates;
  defaultZoom?: number;         // 1-20 (1 = world, 20 = building)

  // Clustering for many markers
  enableClustering?: boolean;   // Group nearby markers at low zoom
  clusterRadius?: number;       // Pixels (default: 50)
}

/**
 * Complete navigation configuration
 */
export interface NavigationConfig {
  type: 'floorplan' | 'map' | 'none';

  // Display settings
  displayMode: 'minimap' | 'fullscreen' | 'both';
  minimapPosition?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
  minimapSize?: 'small' | 'medium' | 'large'; // 150px, 200px, 250px

  // Floor plan (when type = 'floorplan')
  floorPlan?: FloorPlanConfig;
  floorPlans?: FloorPlanConfig[];  // Multi-floor support

  // Map (when type = 'map')
  map?: MapConfig;

  // Markers (shared for both types)
  markers: NavigationMarker[];

  // VR settings
  vrDisplay?: 'floating-panel' | 'wrist-mounted' | 'hidden';
}

Updated ExperienceConfig

export interface ExperienceConfig {
  experience: {
    title: string;
    description?: string;
  };
  stages: ExperienceStage[];
  navigation?: NavigationConfig;  // NEW: Optional navigation overlay
}

Updated ExperienceStage (add optional geo-location)

export interface ExperienceStage {
  id: string;
  name?: string;
  skybox?: SkyboxConfig;
  models?: ModelConfig[];
  planes?: PlaneConfig[];
  audioUrl?: string;
  audioTitle?: string;
  lights?: LightConfig[];
  hotspots?: HotspotConfig[];

  // NEW: Geographic location (for map-based navigation)
  location?: GeoCoordinates;
}

Configuration Examples

Example 1: Museum Floor Plan

{
  "experience": {
    "title": "Natural History Museum Tour",
    "description": "Explore the museum's famous exhibits"
  },
  "navigation": {
    "type": "floorplan",
    "displayMode": "both",
    "minimapPosition": "bottom-left",
    "minimapSize": "medium",
    "floorPlan": {
      "imageUrl": "https://storage.googleapis.com/bucket/museum-floorplan.png",
      "realWorldWidth": 100,
      "realWorldHeight": 80,
      "floor": 1,
      "floorLabel": "Main Floor"
    },
    "markers": [
      {
        "stageId": "entrance",
        "label": "Main Entrance",
        "floorPlanPosition": { "x": 0.5, "y": 0.95 },
        "icon": "default",
        "initialViewDirection": 0
      },
      {
        "stageId": "dinosaur-hall",
        "label": "Dinosaur Hall",
        "floorPlanPosition": { "x": 0.3, "y": 0.5 },
        "icon": "star",
        "color": "#FF6B6B"
      },
      {
        "stageId": "ocean-life",
        "label": "Ocean Life",
        "floorPlanPosition": { "x": 0.7, "y": 0.5 },
        "icon": "camera"
      },
      {
        "stageId": "gift-shop",
        "label": "Gift Shop",
        "floorPlanPosition": { "x": 0.5, "y": 0.1 },
        "icon": "info"
      }
    ],
    "vrDisplay": "floating-panel"
  },
  "stages": [
    {
      "id": "entrance",
      "name": "Main Entrance",
      "skybox": { "type": "image", "url": "https://.../entrance-360.jpg" }
    },
    {
      "id": "dinosaur-hall",
      "name": "Dinosaur Hall",
      "skybox": { "type": "image", "url": "https://.../dino-360.jpg" }
    },
    {
      "id": "ocean-life",
      "name": "Ocean Life",
      "skybox": { "type": "video", "url": "https://.../ocean-360.mp4" }
    },
    {
      "id": "gift-shop",
      "name": "Gift Shop",
      "skybox": { "type": "image", "url": "https://.../shop-360.jpg" }
    }
  ]
}

Example 2: Multi-Floor Building

{
  "experience": {
    "title": "Corporate Headquarters Tour"
  },
  "navigation": {
    "type": "floorplan",
    "displayMode": "both",
    "floorPlans": [
      {
        "imageUrl": "https://.../floor-1.png",
        "floor": 1,
        "floorLabel": "Lobby & Reception"
      },
      {
        "imageUrl": "https://.../floor-2.png",
        "floor": 2,
        "floorLabel": "Engineering"
      },
      {
        "imageUrl": "https://.../floor-3.png",
        "floor": 3,
        "floorLabel": "Executive Suite"
      }
    ],
    "markers": [
      { "stageId": "lobby", "floorPlanPosition": { "x": 0.5, "y": 0.8 } },
      { "stageId": "cafeteria", "floorPlanPosition": { "x": 0.2, "y": 0.3 } },
      { "stageId": "engineering-open", "floorPlanPosition": { "x": 0.5, "y": 0.5 } },
      { "stageId": "ceo-office", "floorPlanPosition": { "x": 0.8, "y": 0.2 } }
    ]
  },
  "stages": [
    { "id": "lobby", "name": "Lobby" },
    { "id": "cafeteria", "name": "Cafeteria" },
    { "id": "engineering-open", "name": "Engineering Open Space" },
    { "id": "ceo-office", "name": "CEO Office" }
  ]
}

Example 3: National Parks Map Tour

{
  "experience": {
    "title": "America's National Parks VR Tour",
    "description": "Visit the most breathtaking parks from coast to coast"
  },
  "navigation": {
    "type": "map",
    "displayMode": "both",
    "minimapPosition": "bottom-right",
    "map": {
      "style": "terrain",
      "defaultCenter": { "lat": 39.8, "lng": -98.5 },
      "defaultZoom": 4,
      "enableClustering": false
    },
    "markers": [
      {
        "stageId": "yellowstone",
        "geoPosition": { "lat": 44.4280, "lng": -110.5885 },
        "icon": "star",
        "color": "#FFD700"
      },
      {
        "stageId": "grand-canyon",
        "geoPosition": { "lat": 36.0544, "lng": -112.1401 },
        "icon": "camera"
      },
      {
        "stageId": "yosemite",
        "geoPosition": { "lat": 37.8651, "lng": -119.5383 },
        "icon": "video"
      },
      {
        "stageId": "zion",
        "geoPosition": { "lat": 37.2982, "lng": -113.0263 }
      },
      {
        "stageId": "acadia",
        "geoPosition": { "lat": 44.3386, "lng": -68.2733 }
      }
    ],
    "vrDisplay": "floating-panel"
  },
  "stages": [
    {
      "id": "yellowstone",
      "name": "Yellowstone - Old Faithful",
      "location": { "lat": 44.4280, "lng": -110.5885 },
      "skybox": { "type": "video", "url": "https://.../yellowstone-360.mp4" }
    },
    {
      "id": "grand-canyon",
      "name": "Grand Canyon South Rim",
      "location": { "lat": 36.0544, "lng": -112.1401 },
      "skybox": { "type": "image", "url": "https://.../grand-canyon-360.jpg" }
    },
    {
      "id": "yosemite",
      "name": "Yosemite Valley",
      "location": { "lat": 37.8651, "lng": -119.5383 },
      "skybox": { "type": "video", "url": "https://.../yosemite-360.mp4" }
    },
    {
      "id": "zion",
      "name": "Zion National Park",
      "location": { "lat": 37.2982, "lng": -113.0263 },
      "skybox": { "type": "image", "url": "https://.../zion-360.jpg" }
    },
    {
      "id": "acadia",
      "name": "Acadia National Park",
      "location": { "lat": 44.3386, "lng": -68.2733 },
      "skybox": { "type": "image", "url": "https://.../acadia-360.jpg" }
    }
  ]
}

Example 4: World Heritage Sites (Global Map)

{
  "experience": {
    "title": "UNESCO World Heritage VR Experience"
  },
  "navigation": {
    "type": "map",
    "displayMode": "fullscreen",
    "map": {
      "style": "satellite",
      "defaultZoom": 2,
      "enableClustering": true,
      "clusterRadius": 60
    },
    "markers": [
      {
        "stageId": "machu-picchu",
        "geoPosition": { "lat": -13.1631, "lng": -72.5450 },
        "icon": "star"
      },
      {
        "stageId": "taj-mahal",
        "geoPosition": { "lat": 27.1751, "lng": 78.0421 },
        "icon": "star"
      },
      {
        "stageId": "great-wall",
        "geoPosition": { "lat": 40.4319, "lng": 116.5704 },
        "icon": "star"
      },
      {
        "stageId": "colosseum",
        "geoPosition": { "lat": 41.8902, "lng": 12.4922 },
        "icon": "star"
      },
      {
        "stageId": "petra",
        "geoPosition": { "lat": 30.3285, "lng": 35.4444 },
        "icon": "star"
      }
    ]
  },
  "stages": []
}

Component Architecture

New Files to Create

src/
├── types/
│   └── config.ts              # Add navigation types here
├── ui/
│   ├── UIManager.ts           # Integrate navigation overlay
│   ├── NavigationOverlay.ts   # NEW: Base class / orchestrator
│   ├── FloorPlanOverlay.ts    # NEW: Floor plan implementation
│   └── MapOverlay.ts          # NEW: Interactive map implementation
└── utils/
    └── MapProvider.ts         # NEW: Mapbox/Leaflet wrapper

dashboard/
├── editor.ts                  # Add navigation editor tab
└── editor.html                # Add navigation UI

Component Relationships

┌─────────────────────────────────────────────────────────┐
│                      UIManager                          │
│  ┌───────────────────────────────────────────────────┐  │
│  │              NavigationOverlay                     │  │
│  │                                                    │  │
│  │   ┌─────────────────┐   ┌─────────────────────┐   │  │
│  │   │ FloorPlanOverlay│   │    MapOverlay       │   │  │
│  │   │                 │   │                     │   │  │
│  │   │ - Canvas/SVG    │   │ - Mapbox GL JS     │   │  │
│  │   │ - Image loading │   │ - Leaflet fallback │   │  │
│  │   │ - Pin placement │   │ - Marker clustering│   │  │
│  │   └─────────────────┘   └─────────────────────┘   │  │
│  │                                                    │  │
│  │   Events:                                          │  │
│  │   - onMarkerClick(stageId) → SceneManager         │  │
│  │   - onMarkerHover(stageId) → Show tooltip         │  │
│  └───────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

Interface

// src/ui/NavigationOverlay.ts

export interface NavigationOverlayEvents {
  onMarkerClick: (stageId: string) => void;
  onMarkerHover: (stageId: string | null) => void;
  onExpandToggle: (isExpanded: boolean) => void;
}

export abstract class NavigationOverlay {
  protected container: HTMLElement;
  protected config: NavigationConfig;
  protected currentStageId: string | null = null;
  protected events: NavigationOverlayEvents;

  constructor(
    container: HTMLElement,
    config: NavigationConfig,
    events: NavigationOverlayEvents
  ) {
    this.container = container;
    this.config = config;
    this.events = events;
  }

  abstract render(): void;
  abstract updateCurrentStage(stageId: string): void;
  abstract setExpanded(expanded: boolean): void;
  abstract dispose(): void;

  // For VR mode
  abstract createVRPanel(): Mesh | null;
}

FloorPlanOverlay Implementation

Desktop HTML Structure

<!-- Injected by FloorPlanOverlay -->
<div class="floor-plan-overlay" data-mode="minimap">
  <!-- Mini-map (collapsed) -->
  <div class="floor-plan-minimap">
    <div class="floor-plan-image-container">
      <img src="floorplan.png" alt="Floor Plan" />
      <svg class="floor-plan-markers">
        <!-- Markers rendered here -->
        <circle class="marker current" cx="50%" cy="80%" r="8" />
        <circle class="marker" cx="30%" cy="50%" r="6" />
        <circle class="marker" cx="70%" cy="50%" r="6" />
      </svg>
      <div class="current-location-pulse"></div>
    </div>
    <button class="floor-plan-expand-btn" aria-label="Expand floor plan">
      <svg><!-- expand icon --></svg>
    </button>
  </div>

  <!-- Full-screen (expanded) -->
  <div class="floor-plan-fullscreen" hidden>
    <div class="floor-plan-header">
      <h3>Floor Plan</h3>
      <div class="floor-selector" data-floors="3">
        <button data-floor="1">Floor 1</button>
        <button data-floor="2" class="active">Floor 2</button>
        <button data-floor="3">Floor 3</button>
      </div>
      <button class="floor-plan-close-btn">×</button>
    </div>
    <div class="floor-plan-content">
      <img src="floorplan.png" alt="Floor Plan" />
      <svg class="floor-plan-markers">
        <!-- Larger markers with labels -->
      </svg>
    </div>
  </div>
</div>

CSS Styles

/* Liquid glass style matching existing UI */
.floor-plan-overlay {
  position: fixed;
  z-index: 100;
  pointer-events: auto;
}

.floor-plan-overlay[data-mode="minimap"] .floor-plan-minimap {
  display: block;
}

.floor-plan-minimap {
  position: fixed;
  bottom: 20px;
  left: 20px;
  width: 200px;
  height: 150px;
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.floor-plan-minimap:hover {
  transform: scale(1.02);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}

.floor-plan-image-container {
  position: relative;
  width: 100%;
  height: 100%;
}

.floor-plan-image-container img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  opacity: 0.9;
}

.floor-plan-markers {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.marker {
  fill: rgba(74, 144, 226, 0.8);
  stroke: white;
  stroke-width: 2;
  cursor: pointer;
  pointer-events: auto;
  transition: r 0.2s ease, fill 0.2s ease;
}

.marker:hover {
  r: 10;
  fill: rgba(74, 144, 226, 1);
}

.marker.current {
  fill: rgba(255, 107, 107, 1);
  r: 10;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.6; }
}

/* Fullscreen mode */
.floor-plan-fullscreen {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 80vw;
  max-width: 800px;
  height: 70vh;
  background: rgba(20, 20, 30, 0.95);
  backdrop-filter: blur(30px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 24px;
  overflow: hidden;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}

.floor-plan-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 24px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.floor-selector button {
  background: rgba(255, 255, 255, 0.1);
  border: none;
  color: white;
  padding: 8px 16px;
  border-radius: 8px;
  cursor: pointer;
  margin: 0 4px;
}

.floor-selector button.active {
  background: rgba(74, 144, 226, 0.8);
}

Core Logic

// src/ui/FloorPlanOverlay.ts

import { NavigationOverlay, NavigationOverlayEvents } from './NavigationOverlay';
import type { NavigationConfig, NavigationMarker, FloorPlanConfig } from '../types/config';

export class FloorPlanOverlay extends NavigationOverlay {
  private minimapContainer: HTMLElement | null = null;
  private fullscreenContainer: HTMLElement | null = null;
  private currentFloorIndex: number = 0;
  private isExpanded: boolean = false;

  render(): void {
    this.createMinimapView();
    if (this.config.displayMode === 'both' || this.config.displayMode === 'fullscreen') {
      this.createFullscreenView();
    }
    this.renderMarkers();
    this.setupEventListeners();
  }

  private createMinimapView(): void {
    const position = this.config.minimapPosition || 'bottom-left';
    const size = this.config.minimapSize || 'medium';

    this.minimapContainer = document.createElement('div');
    this.minimapContainer.className = `floor-plan-minimap floor-plan-${position} floor-plan-${size}`;
    this.minimapContainer.innerHTML = `
      <div class="floor-plan-image-container">
        <img src="${this.getFloorPlanUrl()}" alt="Floor Plan" />
        <svg class="floor-plan-markers"></svg>
      </div>
      <button class="floor-plan-expand-btn" aria-label="Expand">
        <svg viewBox="0 0 24 24" width="16" height="16">
          <path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
        </svg>
      </button>
    `;
    this.container.appendChild(this.minimapContainer);
  }

  private renderMarkers(): void {
    const svg = this.minimapContainer?.querySelector('.floor-plan-markers');
    if (!svg) return;

    svg.innerHTML = '';

    this.config.markers.forEach(marker => {
      if (!marker.floorPlanPosition) return;

      const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
      circle.setAttribute('cx', `${marker.floorPlanPosition.x * 100}%`);
      circle.setAttribute('cy', `${marker.floorPlanPosition.y * 100}%`);
      circle.setAttribute('r', marker.stageId === this.currentStageId ? '10' : '6');
      circle.setAttribute('class', `marker ${marker.stageId === this.currentStageId ? 'current' : ''}`);
      circle.setAttribute('data-stage-id', marker.stageId);

      if (marker.color) {
        circle.style.fill = marker.color;
      }

      // Click handler
      circle.addEventListener('click', (e) => {
        e.stopPropagation();
        this.events.onMarkerClick(marker.stageId);
      });

      // Hover handler
      circle.addEventListener('mouseenter', () => {
        this.events.onMarkerHover(marker.stageId);
        this.showTooltip(marker);
      });

      circle.addEventListener('mouseleave', () => {
        this.events.onMarkerHover(null);
        this.hideTooltip();
      });

      svg.appendChild(circle);
    });
  }

  updateCurrentStage(stageId: string): void {
    this.currentStageId = stageId;
    this.renderMarkers(); // Re-render with new current marker
  }

  setExpanded(expanded: boolean): void {
    this.isExpanded = expanded;
    if (this.minimapContainer) {
      this.minimapContainer.hidden = expanded;
    }
    if (this.fullscreenContainer) {
      this.fullscreenContainer.hidden = !expanded;
    }
    this.events.onExpandToggle(expanded);
  }

  private getFloorPlanUrl(): string {
    if (this.config.floorPlans && this.config.floorPlans.length > 0) {
      return this.config.floorPlans[this.currentFloorIndex].imageUrl;
    }
    return this.config.floorPlan?.imageUrl || '';
  }

  dispose(): void {
    this.minimapContainer?.remove();
    this.fullscreenContainer?.remove();
  }

  createVRPanel(): Mesh | null {
    // Create Babylon.js plane with floor plan texture
    // Implementation similar to existing VR GUI panels
    return null; // TODO: Implement VR version
  }
}

MapOverlay Implementation

Dependencies

// Add to package.json
{
  "dependencies": {
    "mapbox-gl": "^3.0.0"
  }
}

Or use Leaflet (free, no API key required):

{
  "dependencies": {
    "leaflet": "^1.9.4"
  }
}

Core Logic

// src/ui/MapOverlay.ts

import mapboxgl from 'mapbox-gl';
import { NavigationOverlay, NavigationOverlayEvents } from './NavigationOverlay';
import type { NavigationConfig, NavigationMarker } from '../types/config';

// Set your Mapbox access token (or use env variable)
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_TOKEN || '';

export class MapOverlay extends NavigationOverlay {
  private map: mapboxgl.Map | null = null;
  private markers: Map<string, mapboxgl.Marker> = new Map();
  private minimapContainer: HTMLElement | null = null;

  render(): void {
    this.createMapContainer();
    this.initializeMap();
    this.addMarkers();
  }

  private createMapContainer(): void {
    const position = this.config.minimapPosition || 'bottom-left';

    this.minimapContainer = document.createElement('div');
    this.minimapContainer.className = `map-overlay map-${position}`;
    this.minimapContainer.innerHTML = `
      <div class="map-container" id="navigation-map"></div>
      <button class="map-expand-btn" aria-label="Expand map">
        <svg viewBox="0 0 24 24" width="16" height="16">
          <path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
        </svg>
      </button>
    `;
    this.container.appendChild(this.minimapContainer);
  }

  private initializeMap(): void {
    const mapConfig = this.config.map;
    if (!mapConfig) return;

    const styleMap: Record<string, string> = {
      streets: 'mapbox://styles/mapbox/streets-v12',
      satellite: 'mapbox://styles/mapbox/satellite-streets-v12',
      terrain: 'mapbox://styles/mapbox/outdoors-v12',
      dark: 'mapbox://styles/mapbox/dark-v11',
      light: 'mapbox://styles/mapbox/light-v11',
    };

    this.map = new mapboxgl.Map({
      container: 'navigation-map',
      style: styleMap[mapConfig.style] || styleMap.terrain,
      center: mapConfig.defaultCenter
        ? [mapConfig.defaultCenter.lng, mapConfig.defaultCenter.lat]
        : [-98.5, 39.8], // Center of USA
      zoom: mapConfig.defaultZoom || 4,
      attributionControl: false,
    });

    // Fit bounds if specified
    if (mapConfig.bounds) {
      this.map.fitBounds([
        [mapConfig.bounds.west, mapConfig.bounds.south],
        [mapConfig.bounds.east, mapConfig.bounds.north],
      ], { padding: 50 });
    }

    // Auto-fit to markers if no bounds specified
    else if (this.config.markers.length > 0) {
      this.fitToMarkers();
    }
  }

  private addMarkers(): void {
    if (!this.map) return;

    this.config.markers.forEach(marker => {
      if (!marker.geoPosition) return;

      // Create marker element
      const el = document.createElement('div');
      el.className = `map-marker ${marker.stageId === this.currentStageId ? 'current' : ''}`;
      el.style.backgroundColor = marker.color || '#4A90E2';
      el.setAttribute('data-stage-id', marker.stageId);

      // Add icon if specified
      if (marker.icon && marker.icon !== 'default') {
        el.innerHTML = this.getMarkerIcon(marker.icon);
      }

      // Create Mapbox marker
      const mapboxMarker = new mapboxgl.Marker({ element: el })
        .setLngLat([marker.geoPosition.lng, marker.geoPosition.lat])
        .addTo(this.map!);

      // Add popup
      const popup = new mapboxgl.Popup({ offset: 25, closeButton: false })
        .setText(marker.label || marker.stageId);

      mapboxMarker.setPopup(popup);

      // Click handler
      el.addEventListener('click', () => {
        this.events.onMarkerClick(marker.stageId);
      });

      this.markers.set(marker.stageId, mapboxMarker);
    });
  }

  private fitToMarkers(): void {
    if (!this.map || this.config.markers.length === 0) return;

    const bounds = new mapboxgl.LngLatBounds();

    this.config.markers.forEach(marker => {
      if (marker.geoPosition) {
        bounds.extend([marker.geoPosition.lng, marker.geoPosition.lat]);
      }
    });

    this.map.fitBounds(bounds, { padding: 50, maxZoom: 15 });
  }

  updateCurrentStage(stageId: string): void {
    // Update previous current marker
    if (this.currentStageId) {
      const prevMarker = this.markers.get(this.currentStageId);
      prevMarker?.getElement()?.classList.remove('current');
    }

    // Update new current marker
    this.currentStageId = stageId;
    const currentMarker = this.markers.get(stageId);
    currentMarker?.getElement()?.classList.add('current');

    // Center map on current marker
    const markerConfig = this.config.markers.find(m => m.stageId === stageId);
    if (markerConfig?.geoPosition && this.map) {
      this.map.flyTo({
        center: [markerConfig.geoPosition.lng, markerConfig.geoPosition.lat],
        zoom: Math.max(this.map.getZoom(), 8),
        duration: 1000,
      });
    }
  }

  private getMarkerIcon(icon: string): string {
    const icons: Record<string, string> = {
      star: '★',
      camera: '📷',
      video: '🎬',
      audio: '🔊',
      info: 'ℹ',
    };
    return icons[icon] || '';
  }

  setExpanded(expanded: boolean): void {
    // Toggle fullscreen map view
  }

  dispose(): void {
    this.map?.remove();
    this.minimapContainer?.remove();
  }

  createVRPanel(): Mesh | null {
    // For VR: render map as a static texture on a plane
    // (Interactive maps in VR are complex - static snapshot is simpler)
    return null;
  }
}

Map CSS

.map-overlay {
  position: fixed;
  z-index: 100;
}

.map-overlay.map-bottom-left {
  bottom: 20px;
  left: 20px;
}

.map-overlay.map-bottom-right {
  bottom: 20px;
  right: 20px;
}

.map-container {
  width: 250px;
  height: 180px;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
  border: 1px solid rgba(255, 255, 255, 0.2);
}

.map-marker {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  border: 3px solid white;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
  transition: transform 0.2s ease;
}

.map-marker:hover {
  transform: scale(1.2);
}

.map-marker.current {
  background-color: #FF6B6B !important;
  transform: scale(1.3);
  animation: marker-pulse 2s infinite;
}

@keyframes marker-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(255, 107, 107, 0.4); }
  50% { box-shadow: 0 0 0 10px rgba(255, 107, 107, 0); }
}

Editor Integration

Editor UI (Navigation Tab)

<!-- Add to dashboard/editor.html -->
<div class="tab-content" id="navigation-tab" hidden>
  <h3>Navigation Settings</h3>

  <!-- Navigation Type Selector -->
  <div class="form-group">
    <label>Navigation Type</label>
    <select id="navigation-type">
      <option value="none">None (Use arrows/portals only)</option>
      <option value="floorplan">Floor Plan (Upload image)</option>
      <option value="map">Interactive Map (Geographic)</option>
    </select>
  </div>

  <!-- Floor Plan Settings (shown when type = floorplan) -->
  <div id="floorplan-settings" class="nav-settings" hidden>
    <div class="form-group">
      <label>Floor Plan Image</label>
      <div class="upload-area" id="floorplan-upload">
        <input type="file" accept="image/*" id="floorplan-file" hidden />
        <button class="btn-secondary" onclick="document.getElementById('floorplan-file').click()">
          Upload Floor Plan
        </button>
        <span class="or">or</span>
        <input type="text" id="floorplan-url" placeholder="Paste image URL" />
      </div>
    </div>

    <!-- Floor Plan Preview with Pin Placement -->
    <div class="form-group">
      <label>Place Stage Markers</label>
      <p class="hint">Click on the floor plan to place markers for each stage</p>
      <div class="floorplan-editor" id="floorplan-editor">
        <img id="floorplan-preview" src="" alt="Floor plan" />
        <svg id="floorplan-markers-svg"></svg>
        <div class="marker-tooltip" id="marker-tooltip" hidden></div>
      </div>
    </div>

    <!-- Marker List -->
    <div class="form-group">
      <label>Markers</label>
      <div id="floorplan-markers-list"></div>
      <button class="btn-secondary" id="add-marker-btn">+ Add Marker</button>
    </div>
  </div>

  <!-- Map Settings (shown when type = map) -->
  <div id="map-settings" class="nav-settings" hidden>
    <div class="form-group">
      <label>Map Style</label>
      <select id="map-style">
        <option value="terrain">Terrain (Outdoors)</option>
        <option value="streets">Streets</option>
        <option value="satellite">Satellite</option>
        <option value="dark">Dark</option>
        <option value="light">Light</option>
      </select>
    </div>

    <!-- Map Preview with Pin Placement -->
    <div class="form-group">
      <label>Place Stage Markers</label>
      <p class="hint">Search for locations or click on the map to place markers</p>
      <div class="map-editor" id="map-editor">
        <div id="map-preview"></div>
      </div>
      <div class="map-search">
        <input type="text" id="location-search" placeholder="Search location..." />
        <button id="search-location-btn">Search</button>
      </div>
    </div>

    <!-- Marker List (with coordinates) -->
    <div class="form-group">
      <label>Markers</label>
      <div id="map-markers-list"></div>
    </div>
  </div>

  <!-- Display Settings (shared) -->
  <div id="display-settings" hidden>
    <h4>Display Settings</h4>
    <div class="form-group">
      <label>Display Mode</label>
      <select id="nav-display-mode">
        <option value="both">Mini-map + Fullscreen</option>
        <option value="minimap">Mini-map only</option>
        <option value="fullscreen">Fullscreen only</option>
      </select>
    </div>

    <div class="form-group">
      <label>Mini-map Position</label>
      <select id="minimap-position">
        <option value="bottom-left">Bottom Left</option>
        <option value="bottom-right">Bottom Right</option>
        <option value="top-left">Top Left</option>
        <option value="top-right">Top Right</option>
      </select>
    </div>

    <div class="form-group">
      <label>VR Display</label>
      <select id="vr-nav-display">
        <option value="floating-panel">Floating Panel</option>
        <option value="wrist-mounted">Wrist Mounted</option>
        <option value="hidden">Hidden in VR</option>
      </select>
    </div>
  </div>
</div>

Editor CSS

.floorplan-editor {
  position: relative;
  width: 100%;
  max-width: 600px;
  background: #1a1a2e;
  border-radius: 12px;
  overflow: hidden;
  cursor: crosshair;
}

.floorplan-editor img {
  width: 100%;
  display: block;
}

.floorplan-editor svg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.floorplan-editor .marker-pin {
  cursor: pointer;
  pointer-events: auto;
}

.map-editor {
  width: 100%;
  height: 400px;
  border-radius: 12px;
  overflow: hidden;
}

#map-preview {
  width: 100%;
  height: 100%;
}

.marker-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px;
  background: rgba(255, 255, 255, 0.05);
  border-radius: 8px;
  margin-bottom: 8px;
}

.marker-item .marker-stage {
  font-weight: 500;
}

.marker-item .marker-coords {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.6);
}

.marker-item .marker-actions button {
  background: none;
  border: none;
  color: rgba(255, 255, 255, 0.6);
  cursor: pointer;
  padding: 4px 8px;
}

.marker-item .marker-actions button:hover {
  color: white;
}

Editor TypeScript Logic

// Add to dashboard/editor.ts

// Navigation state
let navigationConfig: NavigationConfig | null = null;
let floorPlanMarkerPlacement: { stageId: string; x: number; y: number } | null = null;

function initNavigationEditor(): void {
  const typeSelect = document.getElementById('navigation-type') as HTMLSelectElement;
  const floorplanSettings = document.getElementById('floorplan-settings');
  const mapSettings = document.getElementById('map-settings');
  const displaySettings = document.getElementById('display-settings');

  typeSelect?.addEventListener('change', () => {
    const type = typeSelect.value;

    floorplanSettings!.hidden = type !== 'floorplan';
    mapSettings!.hidden = type !== 'map';
    displaySettings!.hidden = type === 'none';

    if (type === 'map') {
      initMapEditor();
    }
  });

  // Floor plan upload
  const floorplanFile = document.getElementById('floorplan-file') as HTMLInputElement;
  floorplanFile?.addEventListener('change', handleFloorPlanUpload);

  // Floor plan click-to-place marker
  const floorplanEditor = document.getElementById('floorplan-editor');
  floorplanEditor?.addEventListener('click', handleFloorPlanClick);
}

async function handleFloorPlanUpload(e: Event): Promise<void> {
  const input = e.target as HTMLInputElement;
  const file = input.files?.[0];
  if (!file) return;

  // Upload to Firebase Storage or show local preview
  const preview = document.getElementById('floorplan-preview') as HTMLImageElement;
  preview.src = URL.createObjectURL(file);

  // TODO: Upload to Firebase Storage and get URL
}

function handleFloorPlanClick(e: MouseEvent): void {
  const editor = document.getElementById('floorplan-editor');
  const preview = document.getElementById('floorplan-preview') as HTMLImageElement;
  if (!editor || !preview) return;

  const rect = preview.getBoundingClientRect();
  const x = (e.clientX - rect.left) / rect.width;
  const y = (e.clientY - rect.top) / rect.height;

  // Show stage selector popup
  showStageSelector(x, y);
}

function showStageSelector(x: number, y: number): void {
  // Show popup with list of stages to assign to this marker
  const stages = currentSequence?.stages || [];

  // Create popup
  const popup = document.createElement('div');
  popup.className = 'marker-stage-popup';
  popup.innerHTML = `
    <div class="popup-header">Select Stage for Marker</div>
    <div class="popup-list">
      ${stages.map((stage, i) => `
        <button class="popup-stage-btn" data-stage-id="${stage.id}">
          ${stage.name || `Stage ${i + 1}`}
        </button>
      `).join('')}
    </div>
    <button class="popup-cancel">Cancel</button>
  `;

  document.body.appendChild(popup);

  // Handle stage selection
  popup.querySelectorAll('.popup-stage-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const stageId = btn.getAttribute('data-stage-id')!;
      addFloorPlanMarker(stageId, x, y);
      popup.remove();
    });
  });

  popup.querySelector('.popup-cancel')?.addEventListener('click', () => {
    popup.remove();
  });
}

function addFloorPlanMarker(stageId: string, x: number, y: number): void {
  if (!navigationConfig) {
    navigationConfig = {
      type: 'floorplan',
      displayMode: 'both',
      markers: [],
    };
  }

  // Check if marker already exists for this stage
  const existingIndex = navigationConfig.markers.findIndex(m => m.stageId === stageId);

  const marker: NavigationMarker = {
    stageId,
    floorPlanPosition: { x, y },
  };

  if (existingIndex >= 0) {
    navigationConfig.markers[existingIndex] = marker;
  } else {
    navigationConfig.markers.push(marker);
  }

  renderFloorPlanMarkers();
  renderMarkersList();
}

function renderFloorPlanMarkers(): void {
  const svg = document.getElementById('floorplan-markers-svg');
  if (!svg || !navigationConfig) return;

  svg.innerHTML = '';

  navigationConfig.markers.forEach((marker, index) => {
    if (!marker.floorPlanPosition) return;

    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    circle.setAttribute('cx', `${marker.floorPlanPosition.x * 100}%`);
    circle.setAttribute('cy', `${marker.floorPlanPosition.y * 100}%`);
    circle.setAttribute('r', '12');
    circle.setAttribute('fill', marker.color || '#4A90E2');
    circle.setAttribute('stroke', 'white');
    circle.setAttribute('stroke-width', '3');
    circle.setAttribute('class', 'marker-pin');
    circle.setAttribute('data-index', index.toString());

    // Add stage number label
    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
    text.setAttribute('x', `${marker.floorPlanPosition.x * 100}%`);
    text.setAttribute('y', `${marker.floorPlanPosition.y * 100}%`);
    text.setAttribute('text-anchor', 'middle');
    text.setAttribute('dy', '4');
    text.setAttribute('fill', 'white');
    text.setAttribute('font-size', '10');
    text.setAttribute('font-weight', 'bold');
    text.textContent = (index + 1).toString();

    svg.appendChild(circle);
    svg.appendChild(text);
  });
}

function renderMarkersList(): void {
  const list = document.getElementById('floorplan-markers-list');
  if (!list || !navigationConfig) return;

  list.innerHTML = navigationConfig.markers.map((marker, index) => {
    const stage = currentSequence?.stages.find(s => s.id === marker.stageId);
    const stageName = stage?.name || marker.stageId;

    return `
      <div class="marker-item" data-index="${index}">
        <div class="marker-info">
          <span class="marker-number">${index + 1}</span>
          <span class="marker-stage">${stageName}</span>
          ${marker.floorPlanPosition ? `
            <span class="marker-coords">
              (${(marker.floorPlanPosition.x * 100).toFixed(0)}%, ${(marker.floorPlanPosition.y * 100).toFixed(0)}%)
            </span>
          ` : ''}
        </div>
        <div class="marker-actions">
          <button onclick="repositionMarker(${index})" title="Reposition">📍</button>
          <button onclick="deleteMarker(${index})" title="Delete">🗑️</button>
        </div>
      </div>
    `;
  }).join('');
}

Implementation Roadmap

Phase 1: Floor Plans (Week 1)

  1. Add types to src/types/config.ts
  2. Create FloorPlanOverlay component
  3. Add floor plan tab to editor
  4. Implement pin placement in editor
  5. Test with sample museum config

Phase 2: Interactive Maps (Week 2)

  1. Add Mapbox GL or Leaflet dependency
  2. Create MapOverlay component
  3. Add map settings to editor
  4. Implement location search + pin placement
  5. Test with national parks config

Phase 3: Polish & VR (Week 3)

  1. Multi-floor support for floor plans
  2. VR floating panel implementation
  3. Marker clustering for maps
  4. Mobile-responsive overlays
  5. Documentation and examples

Dependencies to Add

# For interactive maps (choose one)
npm install mapbox-gl    # Requires API key (free tier: 50K loads/mo)
# OR
npm install leaflet      # Free, no API key needed

Environment Variables

# .env
VITE_MAPBOX_TOKEN=pk.eyJ1Ijoi...  # Only if using Mapbox

Testing Checklist

  • Floor plan image uploads correctly
  • Markers can be placed by clicking on floor plan
  • Markers persist after save
  • Clicking marker navigates to correct stage
  • Current stage marker is highlighted
  • Mini-map shows in viewer
  • Fullscreen floor plan works
  • Multi-floor switching works
  • Map loads with correct style
  • Map markers show at correct coordinates
  • Location search works
  • Map auto-fits to marker bounds
  • Map marker clustering works
  • VR panel displays correctly
  • Mobile touch interactions work

Document Version: 1.0 Last Updated: November 2025