UIPackage
Menu

Framework

Change language

Boilerplate repo

Virtual Tour Panorama

blockreal-estate

360-degree immersive room virtual tour panorama viewer with interactive viewpoint hotspots, laser measurement overlays, and architectural floor plan minimap.

Also available for Vue ->

Installation

$npx shadcn@latest add https://uipkge.dev/r/react/virtual-tour-panorama.json
Named registry:npx shadcn@latest add @uipkge-react/virtual-tour-panoramaInstalls to:components/blocks/virtual-tour-panorama/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
roomsRoomData[]optional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

RoomHotspot
interface RoomHotspot {
  id: string
  type: 'portal' | 'info'
  x: number // Base % (0 - 100)
  y: number // Pitch % (0 - 100)
  title: string
  subtitle: string
  targetRoomId?: string
  infoData?: {
    category: string
    specs: string[]
    description: string
    highlight: string
    material: string
  }
}
RoomMeasurement
interface RoomMeasurement {
  id: string
  title: string
  imperial: string
  metric: string
  x1: number
  y1: number
  x2: number
  y2: number
  labelX: number
  labelY: number
}
RoomData
interface RoomData {
  id: string
  name: string
  badgeName: string
  floor: string
  area: string
  exposure: string
  ceilingHeight: string
  description: string
  skylineStyle: string
  floorPlanCoords: { cx: number; cy: number; x: number; y: number; w: number; h: number; label: string }
  hotspots: RoomHotspot[]
  measurements: RoomMeasurement[]
  features: { label: string; value: string }[]
}

npm dependencies

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--success

Files installed (7)

  • components/blocks/virtual-tour-panorama/VirtualTourPanorama.tsx25.4 kB
    'use client'
    
    import * as React from 'react'
    import {
      ArrowRight,
      Bath,
      Bed,
      Check,
      ChevronDown,
      ChevronLeft,
      ChevronRight,
      ChevronUp,
      Compass,
      Glasses,
      Home,
      Info,
      Map as MapIcon,
      Maximize2,
      Minimize2,
      Navigation,
      Pause,
      Play,
      RotateCcw,
      Ruler,
      Share2,
      Sofa,
      Sun,
      Utensils,
      Volume2,
      VolumeX,
      ZoomIn,
      ZoomOut,
    } from 'lucide-react'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Separator } from '@/components/ui/separator'
    import { TourFloorPlanNavigator } from './TourFloorPlanNavigator'
    import { TourHotspotModal } from './TourHotspotModal'
    import { TourPanoramaScene } from './TourPanoramaScene'
    import { TourRoomDetails } from './TourRoomDetails'
    import { defaultRooms } from './virtual-tour-panorama-data'
    import { type RoomData, type RoomHotspot } from './virtual-tour-panorama-types'
    
    export interface VirtualTourPanoramaProps {
      rooms?: RoomData[]
    }
    
    function getRoomIcon(roomId: string) {
      switch (roomId) {
        case 'master-bedroom':
          return Bed
        case 'living-room':
          return Sofa
        case 'gourmet-kitchen':
          return Utensils
        case 'rooftop-terrace':
          return Sun
        case 'spa-bath':
          return Bath
        default:
          return Home
      }
    }
    
    export function VirtualTourPanorama({ rooms }: VirtualTourPanoramaProps) {
      const allRooms = React.useMemo(() => (rooms && rooms.length > 0 ? rooms : defaultRooms), [rooms])
    
      // State variables
      const [currentRoomId, setCurrentRoomId] = React.useState<string>('master-bedroom')
      const [yaw, setYaw] = React.useState<number>(0)
      const [pitch, setPitch] = React.useState<number>(0)
      const [zoom, setZoom] = React.useState<number>(1.0)
    
      const [isDragging, setIsDragging] = React.useState<boolean>(false)
      const dragStartRef = React.useRef({ x: 0, y: 0, yaw: 0, pitch: 0 })
    
      const [isAutoRotating, setIsAutoRotating] = React.useState<boolean>(false)
      const [isMinimapOpen, setIsMinimapOpen] = React.useState<boolean>(true)
      const [isMeasurementMode, setIsMeasurementMode] = React.useState<boolean>(false)
      const [measurementUnit, setMeasurementUnit] = React.useState<'imperial' | 'metric'>('imperial')
      const [isVrMode, setIsVrMode] = React.useState<boolean>(false)
      const [isAudioActive, setIsAudioActive] = React.useState<boolean>(false)
      const [isFullscreen, setIsFullscreen] = React.useState<boolean>(false)
      const [copiedToast, setCopiedToast] = React.useState<boolean>(false)
      const [isRoomTransitioning, setIsRoomTransitioning] = React.useState<boolean>(false)
      const [activeInfoHotspot, setActiveInfoHotspot] = React.useState<RoomHotspot | null>(null)
      const [hoveredHotspotId, setHoveredHotspotId] = React.useState<string | null>(null)
    
      // Current Room
      const currentRoom = React.useMemo(() => {
        return allRooms.find((r) => r.id === currentRoomId) || allRooms[0]
      }, [allRooms, currentRoomId])
    
      // Normalized Compass Heading
      const compassHeading = React.useMemo(() => {
        const normalized = ((yaw % 360) + 360) % 360
        return Math.round(normalized)
      }, [yaw])
    
      const compassDirection = React.useMemo(() => {
        const h = compassHeading
        if (h >= 337.5 || h < 22.5) return 'N'
        if (h >= 22.5 && h < 67.5) return 'NE'
        if (h >= 67.5 && h < 112.5) return 'E'
        if (h >= 112.5 && h < 157.5) return 'SE'
        if (h >= 157.5 && h < 202.5) return 'S'
        if (h >= 202.5 && h < 247.5) return 'SW'
        if (h >= 247.5 && h < 292.5) return 'W'
        return 'NW'
      }, [compassHeading])
    
      // Auto-rotation loop
      React.useEffect(() => {
        let autoRotateRaf: number | null = null
    
        const updateAutoRotate = () => {
          if (isAutoRotating && !isDragging) {
            setYaw((prev) => (prev + 0.18) % 360)
          }
          autoRotateRaf = requestAnimationFrame(updateAutoRotate)
        }
    
        autoRotateRaf = requestAnimationFrame(updateAutoRotate)
        return () => {
          if (autoRotateRaf) cancelAnimationFrame(autoRotateRaf)
        }
      }, [isAutoRotating, isDragging])
    
      // Pan step helpers
      const panStep = (dx: number, dy: number) => {
        setYaw((prev) => (prev + dx + 3600) % 360)
        setPitch((prev) => Math.max(-22, Math.min(22, prev + dy)))
      }
    
      const zoomStep = (delta: number) => {
        setZoom((prev) => Math.max(0.85, Math.min(1.5, prev + delta)))
      }
    
      const resetView = () => {
        setYaw(0)
        setPitch(0)
        setZoom(1.0)
      }
    
      // Keyboard navigation
      React.useEffect(() => {
        const handleKeydown = (e: KeyboardEvent) => {
          if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName)) return
          if (e.key === 'ArrowLeft') panStep(-10, 0)
          if (e.key === 'ArrowRight') panStep(10, 0)
          if (e.key === 'ArrowUp') panStep(0, 5)
          if (e.key === 'ArrowDown') panStep(0, -5)
          if (e.key === '+' || e.key === '=') zoomStep(0.1)
          if (e.key === '-' || e.key === '_') zoomStep(-0.1)
          if (e.key === 'm' || e.key === 'M') setIsMinimapOpen((prev) => !prev)
          if (e.key === 'r' || e.key === 'R') setIsMeasurementMode((prev) => !prev)
        }
    
        window.addEventListener('keydown', handleKeydown)
        return () => window.removeEventListener('keydown', handleKeydown)
      }, [])
    
      // Room transition
      const switchRoom = (roomId: string) => {
        if (roomId === currentRoomId) return
        setIsRoomTransitioning(true)
        setActiveInfoHotspot(null)
    
        setTimeout(() => {
          setCurrentRoomId(roomId)
          setYaw(0)
          setPitch(0)
          setTimeout(() => {
            setIsRoomTransitioning(false)
          }, 280)
        }, 220)
      }
    
      // Mouse / Touch Interaction Handlers
      const onPointerDown = (e: React.MouseEvent | React.TouchEvent) => {
        setIsDragging(true)
        const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
        const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
        dragStartRef.current = {
          x: clientX,
          y: clientY,
          yaw,
          pitch,
        }
      }
    
      const onPointerMove = (e: React.MouseEvent | React.TouchEvent) => {
        if (!isDragging) return
        const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
        const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
    
        const deltaX = clientX - dragStartRef.current.x
        const deltaY = clientY - dragStartRef.current.y
    
        setYaw((dragStartRef.current.yaw - deltaX * 0.25 + 3600) % 360)
        setPitch(Math.max(-22, Math.min(22, dragStartRef.current.pitch + deltaY * 0.18)))
      }
    
      const onPointerUp = () => {
        setIsDragging(false)
      }
    
      const copyTourShareLink = () => {
        setCopiedToast(true)
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          navigator.clipboard.writeText('https://uipkge.dev/tour/742-evergreen-terrace')
        }
        setTimeout(() => {
          setCopiedToast(false)
        }, 3200)
      }
    
      const toggleFullscreen = () => {
        setIsFullscreen((prev) => !prev)
      }
    
      const getHotspotStyle = (hotspot: RoomHotspot) => {
        const relativeX = Math.max(8, Math.min(92, (hotspot.x - (yaw / 360) * 100 + 150) % 100))
        const relativeY = Math.max(8, Math.min(88, hotspot.y + pitch * 0.4))
        return {
          left: `${relativeX}%`,
          top: `${relativeY}%`,
        }
      }
    
      const CurrentRoomIcon = getRoomIcon(currentRoom.id)
    
      return (
        <div
          data-slot="virtual-tour-panorama"
          className={`bg-background text-foreground w-full space-y-4 font-sans transition-[background-color,padding] duration-300 ${
            isFullscreen ? 'fixed inset-0 z-50 overflow-y-auto bg-black p-4 sm:p-6' : ''
          }`}
        >
          {/* Top Header Bar */}
          <header className="bg-card rounded-xl border p-3.5 shadow-xs sm:px-5 sm:py-3">
            <div className="flex flex-wrap items-center justify-between gap-3">
              {/* Property Identity & Room Badge */}
              <div className="flex flex-wrap items-center gap-2.5 sm:gap-3">
                <div className="flex items-center gap-2">
                  <div className="bg-primary/10 text-primary border-primary/30 flex size-8 items-center justify-center rounded-lg border shadow-xs">
                    <Compass className="size-4 animate-[spin_12s_linear_infinite]" />
                  </div>
                  <div>
                    <div className="flex items-center gap-2">
                      <h2 className="text-foreground text-sm font-bold tracking-tight sm:text-base">
                        742 Evergreen Terrace
                      </h2>
                      <span className="text-muted-foreground hidden text-xs font-medium sm:inline">360° Virtual Tour</span>
                    </div>
                    <p className="text-muted-foreground text-xs">Penthouse Residence 14B · The Evergreen Collection</p>
                  </div>
                </div>
    
                <Separator orientation="vertical" className="hidden h-6 sm:block" />
    
                {/* Dynamic Active Room Badge */}
                <Badge
                  variant="outline"
                  className="border-primary/40 bg-primary/10 text-primary gap-1.5 px-2.5 py-1 text-xs font-semibold"
                >
                  <CurrentRoomIcon className="size-3.5" />
                  <span>{currentRoom.badgeName}</span>
                </Badge>
    
                {/* Resolution & Live Feed Indicator */}
                <div className="text-muted-foreground hidden items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium md:flex">
                  <span className="bg-success size-1.5 animate-pulse rounded-full" />
                  <span>8K Ultra-HD Pano</span>
                  <span>·</span>
                  <span>{currentRoom.floor}</span>
                </div>
              </div>
    
              {/* Top Right Quick Actions */}
              <div className="flex items-center gap-2">
                <Button
                  variant="outline"
                  size="sm"
                  className={`h-8 gap-1.5 px-2.5 text-xs ${isAudioActive ? 'border-info/40 bg-info/10 text-info' : ''}`}
                  aria-label={isAudioActive ? 'Mute ambient soundscape' : 'Play ambient soundscape'}
                  onClick={() => setIsAudioActive((prev) => !prev)}
                >
                  {isAudioActive ? (
                    <Volume2 className="text-info size-3.5" />
                  ) : (
                    <VolumeX className="text-muted-foreground size-3.5" />
                  )}
                  <span className="hidden md:inline">{isAudioActive ? 'Sound On' : 'Ambience'}</span>
                </Button>
    
                <Button
                  variant="outline"
                  size="sm"
                  className={`h-8 gap-1.5 px-2.5 text-xs ${isVrMode ? 'border-chart-1/40 bg-chart-1/10 text-chart-1' : ''}`}
                  aria-label={isVrMode ? 'Exit VR mode' : 'Enter VR mode'}
                  onClick={() => setIsVrMode((prev) => !prev)}
                >
                  <Glasses className="size-3.5" />
                  <span className="hidden sm:inline">{isVrMode ? 'VR Active' : 'VR Mode'}</span>
                </Button>
    
                <Button
                  variant="outline"
                  size="sm"
                  className="relative h-8 gap-1.5 px-2.5 text-xs"
                  aria-label="Share 360 Tour"
                  onClick={copyTourShareLink}
                >
                  {copiedToast ? <Check className="text-success size-3.5" /> : <Share2 className="size-3.5" />}
                  <span className="hidden sm:inline">{copiedToast ? 'Copied!' : 'Share'}</span>
                </Button>
    
                <Button
                  variant="outline"
                  size="icon"
                  className="size-8"
                  aria-label={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
                  onClick={toggleFullscreen}
                >
                  {isFullscreen ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
                </Button>
              </div>
            </div>
          </header>
    
          {/* 360 Panorama Viewport Container */}
          <div
            className="relative aspect-video min-h-[480px] w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950 text-white shadow-sm select-none sm:min-h-[580px]"
            onMouseDown={onPointerDown}
            onMouseMove={onPointerMove}
            onMouseUp={onPointerUp}
            onMouseLeave={onPointerUp}
            onTouchStart={onPointerDown}
            onTouchMove={onPointerMove}
            onTouchEnd={onPointerUp}
          >
            <TourPanoramaScene
              currentRoom={currentRoom}
              yaw={yaw}
              pitch={pitch}
              zoom={zoom}
              isRoomTransitioning={isRoomTransitioning}
              isMeasurementMode={isMeasurementMode}
              measurementUnit={measurementUnit}
            />
    
            {/* Interactive Hotspot Pins Layer */}
            <div className="pointer-events-auto absolute inset-0 size-full">
              {currentRoom.hotspots.map((hotspot) => (
                <div
                  key={hotspot.id}
                  className="absolute -translate-x-1/2 -translate-y-1/2 transition-transform duration-100"
                  style={getHotspotStyle(hotspot)}
                  onMouseEnter={() => setHoveredHotspotId(hotspot.id)}
                  onMouseLeave={() => setHoveredHotspotId(null)}
                >
                  {/* PORTAL HOTSPOT: Room Navigation */}
                  {hotspot.type === 'portal' && (
                    <div className="group relative flex flex-col items-center">
                      <span className="bg-success absolute -inset-2.5 rounded-full opacity-60 duration-1000" />
                      <span className="bg-success/40 absolute -inset-1 rounded-full opacity-80 blur-xs" />
    
                      <button
                        type="button"
                        className="border-success bg-success/90 focus-visible:ring-success relative flex size-9 items-center justify-center rounded-full border-2 text-white shadow-xl backdrop-blur-md transition-transform hover:scale-115 focus-visible:ring-2 focus-visible:outline-none"
                        aria-label={hotspot.title}
                        onClick={(e) => {
                          e.stopPropagation()
                          if (hotspot.targetRoomId) switchRoom(hotspot.targetRoomId)
                        }}
                      >
                        <Navigation className="size-4" />
                      </button>
    
                      <div
                        className={`border-success/40 pointer-events-none absolute top-11 z-30 flex w-52 flex-col items-center rounded-lg border bg-zinc-950/95 p-2 text-center shadow-sm backdrop-blur-md transition-colors duration-200 ${
                          hoveredHotspotId === hotspot.id ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
                        }`}
                      >
                        <div className="text-success flex items-center gap-1 text-xs font-bold">
                          <span>{hotspot.title}</span>
                        </div>
                        <p className="text-muted-foreground mt-0.5 text-xs">{hotspot.subtitle}</p>
                        <span className="text-success mt-1 inline-flex items-center gap-1 font-mono text-xs font-semibold">
                          Click to Transition <ArrowRight className="size-3" />
                        </span>
                      </div>
                    </div>
                  )}
    
                  {/* INFO HOTSPOT: Feature Inspection */}
                  {hotspot.type === 'info' && (
                    <div className="group relative flex flex-col items-center">
                      <span className="bg-info absolute -inset-2.5 rounded-full opacity-60 duration-1000" />
                      <span className="bg-info/40 absolute -inset-1 rounded-full opacity-80 blur-xs" />
    
                      <button
                        type="button"
                        className="border-info bg-info/90 relative flex size-9 items-center justify-center rounded-full border-2 text-white shadow-xl backdrop-blur-md transition-transform hover:scale-115 focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:outline-none"
                        aria-label={hotspot.title}
                        onClick={(e) => {
                          e.stopPropagation()
                          setActiveInfoHotspot(hotspot)
                        }}
                      >
                        <Info className="size-4" />
                      </button>
    
                      <div
                        className={`border-info/40 pointer-events-none absolute top-11 z-30 flex w-52 flex-col items-center rounded-lg border bg-zinc-950/95 p-2 text-center shadow-sm backdrop-blur-md transition-colors duration-200 ${
                          hoveredHotspotId === hotspot.id ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
                        }`}
                      >
                        <div className="text-info flex items-center gap-1 text-xs font-bold">
                          <Info className="size-3" />
                          <span>{hotspot.title}</span>
                        </div>
                        <p className="text-muted-foreground mt-0.5 text-xs">{hotspot.subtitle}</p>
                        <span className="text-info mt-1 inline-flex items-center gap-1 font-mono text-xs font-semibold">
                          Click to Inspect Specs
                        </span>
                      </div>
                    </div>
                  )}
                </div>
              ))}
            </div>
    
            {/* Top Overlay HUD: Compass, Pitch, FOV & Controls */}
            <div className="pointer-events-none absolute inset-x-3.5 top-3.5 z-20 flex flex-wrap items-start justify-between gap-2">
              <div className="pointer-events-auto flex items-center gap-2.5 rounded-lg border border-zinc-700/60 bg-zinc-900/85 px-3 py-1.5 shadow-lg backdrop-blur-md">
                <div className="flex items-center gap-1.5 font-mono text-xs font-bold text-zinc-100">
                  <Compass
                    className="text-primary size-3.5 transition-transform duration-100"
                    style={{ transform: `rotate(${compassHeading}deg)` }}
                  />
                  <span>
                    {compassHeading}° {compassDirection}
                  </span>
                </div>
                <Separator orientation="vertical" className="h-3.5 bg-zinc-700" />
                <span className="text-muted-foreground font-mono text-xs">FOV 85°</span>
                <Separator orientation="vertical" className="h-3.5 bg-zinc-700" />
                <span className="text-muted-foreground font-mono text-xs">
                  Pitch {pitch > 0 ? `+${Math.round(pitch)}°` : `${Math.round(pitch)}°`}
                </span>
              </div>
    
              <div className="pointer-events-auto flex items-center gap-2">
                {isVrMode && (
                  <div className="border-chart-1/50 bg-card/90 text-chart-1 flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-semibold backdrop-blur-md">
                    <Glasses className="size-3.5 animate-pulse" />
                    <span>Gyroscope / VR Sensor Active</span>
                  </div>
                )}
    
                {isMeasurementMode && (
                  <div className="border-destructive/50 flex items-center gap-1 rounded-lg border bg-zinc-900/90 p-1 backdrop-blur-md">
                    <Button
                      size="sm"
                      variant={measurementUnit === 'imperial' ? 'destructive' : 'ghost'}
                      className="h-6 px-2 text-xs"
                      onClick={() => setMeasurementUnit('imperial')}
                    >
                      Imperial (ft)
                    </Button>
                    <Button
                      size="sm"
                      variant={measurementUnit === 'metric' ? 'destructive' : 'ghost'}
                      className="h-6 px-2 text-xs"
                      onClick={() => setMeasurementUnit('metric')}
                    >
                      Metric (m)
                    </Button>
                  </div>
                )}
              </div>
            </div>
    
            {/* Floating 360 Pan & Zoom Controls */}
            <div className="pointer-events-auto absolute top-16 right-3.5 z-20 hidden flex-col items-center gap-1 rounded-lg border border-zinc-800 bg-zinc-900/85 p-1 shadow-xl backdrop-blur-md sm:flex">
              <Button
                variant="ghost"
                size="icon"
                className="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                aria-label="Pan Up"
                onClick={() => panStep(0, 8)}
              >
                <ChevronUp className="size-4" />
              </Button>
              <div className="flex items-center gap-1">
                <Button
                  variant="ghost"
                  size="icon"
                  className="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                  aria-label="Pan Left"
                  onClick={() => panStep(-15, 0)}
                >
                  <ChevronLeft className="size-4" />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  className="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                  aria-label="Reset View"
                  onClick={resetView}
                >
                  <RotateCcw className="size-3.5" />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  className="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                  aria-label="Pan Right"
                  onClick={() => panStep(15, 0)}
                >
                  <ChevronRight className="size-4" />
                </Button>
              </div>
              <Button
                variant="ghost"
                size="icon"
                className="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                aria-label="Pan Down"
                onClick={() => panStep(0, -8)}
              >
                <ChevronDown className="size-4" />
              </Button>
              <Separator className="my-0.5 bg-zinc-800" />
              <Button
                variant="ghost"
                size="icon"
                className="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                aria-label="Zoom In"
                onClick={() => zoomStep(0.15)}
              >
                <ZoomIn className="size-3.5" />
              </Button>
              <Button
                variant="ghost"
                size="icon"
                className="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                aria-label="Zoom Out"
                onClick={() => zoomStep(-0.15)}
              >
                <ZoomOut className="size-3.5" />
              </Button>
            </div>
    
            {/* 2D Floor Plan Minimap Overlay */}
            {isMinimapOpen && (
              <TourFloorPlanNavigator
                rooms={allRooms}
                currentRoomId={currentRoomId}
                currentRoom={currentRoom}
                yaw={yaw}
                onSelectRoom={switchRoom}
                onClose={() => setIsMinimapOpen(false)}
              />
            )}
    
            {/* Feature Inspection Floating Detail Card Modal */}
            {activeInfoHotspot && (
              <TourHotspotModal hotspot={activeInfoHotspot} onClose={() => setActiveInfoHotspot(null)} />
            )}
    
            {/* Bottom Floating Navigation Dock */}
            <div className="pointer-events-auto absolute inset-x-3.5 bottom-3.5 z-30 flex items-center justify-center">
              <div className="flex max-w-full flex-wrap items-center justify-center gap-2 rounded-2xl border border-zinc-700/80 bg-zinc-950/90 p-2 shadow-sm backdrop-blur-xl sm:px-4">
                <Button
                  variant={isAutoRotating ? 'default' : 'secondary'}
                  size="sm"
                  className="h-8 gap-1.5 rounded-full px-3 text-xs font-medium"
                  aria-label={isAutoRotating ? 'Pause 360 auto rotate' : 'Play 360 auto rotate'}
                  onClick={() => setIsAutoRotating((prev) => !prev)}
                >
                  {isAutoRotating ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
                  <span className="hidden sm:inline">{isAutoRotating ? 'Pause Tour' : 'Auto Tour'}</span>
                </Button>
    
                <Separator orientation="vertical" className="hidden h-5 bg-zinc-800 sm:block" />
    
                <div className="flex items-center gap-1.5 overflow-x-auto py-0.5">
                  {allRooms.map((r) => {
                    const RoomIcon = getRoomIcon(r.id)
                    return (
                      <button
                        key={r.id}
                        type="button"
                        className={`group focus-visible:ring-success flex items-center gap-2 rounded-xl px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
                          currentRoomId === r.id
                            ? 'bg-success text-white shadow-md'
                            : 'border border-zinc-800 bg-zinc-900/90 text-zinc-300 hover:bg-zinc-800 hover:text-white'
                        }`}
                        onClick={() => switchRoom(r.id)}
                      >
                        <RoomIcon className="size-3.5" />
                        <span className="whitespace-nowrap">{r.name}</span>
                      </button>
                    )
                  })}
                </div>
    
                <Separator orientation="vertical" className="hidden h-5 bg-zinc-800 sm:block" />
    
                <Button
                  variant={isMinimapOpen ? 'default' : 'secondary'}
                  size="sm"
                  className="h-8 gap-1.5 rounded-full px-2.5 text-xs"
                  aria-label={isMinimapOpen ? 'Hide floor plan minimap' : 'Show floor plan minimap'}
                  onClick={() => setIsMinimapOpen((prev) => !prev)}
                >
                  <MapIcon className="size-3.5" />
                  <span className="hidden md:inline">Minimap</span>
                </Button>
    
                <Button
                  variant={isMeasurementMode ? 'destructive' : 'secondary'}
                  size="sm"
                  className="h-8 gap-1.5 rounded-full px-2.5 text-xs"
                  aria-label={isMeasurementMode ? 'Turn off measurement mode' : 'Turn on measurement mode'}
                  onClick={() => setIsMeasurementMode((prev) => !prev)}
                >
                  <Ruler className="size-3.5" />
                  <span className="hidden md:inline">Measure</span>
                </Button>
              </div>
            </div>
          </div>
    
          {/* Architectural Deep Dive Specs & Floor Schedule Cards */}
          <TourRoomDetails currentRoom={currentRoom} />
        </div>
      )
    }
    
    export default VirtualTourPanorama
    
  • components/blocks/virtual-tour-panorama/TourFloorPlanNavigator.tsx4.4 kB
  • components/blocks/virtual-tour-panorama/TourHotspotModal.tsx2.6 kB
  • components/blocks/virtual-tour-panorama/TourPanoramaScene.tsx11.9 kB
  • components/blocks/virtual-tour-panorama/TourRoomDetails.tsx6.6 kB
  • components/blocks/virtual-tour-panorama/virtual-tour-panorama-data.ts18.4 kB
  • components/blocks/virtual-tour-panorama/virtual-tour-panorama-types.ts0.9 kB

Raw manifest:https://uipkge.dev/r/react/virtual-tour-panorama.json