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 │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
NavigationOverlay Component¶
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¶
Or use Leaflet (free, no API key required):
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)¶
- Add types to
src/types/config.ts - Create
FloorPlanOverlaycomponent - Add floor plan tab to editor
- Implement pin placement in editor
- Test with sample museum config
Phase 2: Interactive Maps (Week 2)¶
- Add Mapbox GL or Leaflet dependency
- Create
MapOverlaycomponent - Add map settings to editor
- Implement location search + pin placement
- Test with national parks config
Phase 3: Polish & VR (Week 3)¶
- Multi-floor support for floor plans
- VR floating panel implementation
- Marker clustering for maps
- Mobile-responsive overlays
- 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¶
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