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 React ->

Installation

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

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
roomsRoomData[]() => defaultRooms,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 }[]
}

Theming

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

--success

Files installed (7)

  • app/components/blocks/virtual-tour-panorama/VirtualTourPanorama.vue23.7 kB
    <script setup lang="ts">
    import { computed, onMounted, onUnmounted, ref } from 'vue'
    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-vue-next'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Separator } from '@/components/ui/separator'
    import TourFloorPlanNavigator from './TourFloorPlanNavigator.vue'
    import TourHotspotModal from './TourHotspotModal.vue'
    import TourPanoramaScene from './TourPanoramaScene.vue'
    import TourRoomDetails from './TourRoomDetails.vue'
    import { defaultRooms } from './virtual-tour-panorama-data'
    import type { RoomData, RoomHotspot } from './virtual-tour-panorama-types'
    
    const props = withDefaults(
      defineProps<{
        rooms?: RoomData[]
      }>(),
      {
        rooms: () => defaultRooms,
      },
    )
    
    const allRooms = computed(() => (props.rooms && props.rooms.length > 0 ? props.rooms : defaultRooms))
    
    // State variables
    const currentRoomId = ref<string>('master-bedroom')
    const yaw = ref<number>(0) // horizontal pan angle in degrees
    const pitch = ref<number>(0) // vertical tilt in degrees (-20 to 20)
    const zoom = ref<number>(1.0) // zoom level (0.9 to 1.6)
    
    const isDragging = ref<boolean>(false)
    const dragStartX = ref<number>(0)
    const dragStartY = ref<number>(0)
    const startYaw = ref<number>(0)
    const startPitch = ref<number>(0)
    
    const isAutoRotating = ref<boolean>(false)
    const isMinimapOpen = ref<boolean>(true)
    const isMeasurementMode = ref<boolean>(false)
    const measurementUnit = ref<'imperial' | 'metric'>('imperial')
    const isVrMode = ref<boolean>(false)
    const isAudioActive = ref<boolean>(false)
    const isFullscreen = ref<boolean>(false)
    const copiedToast = ref<boolean>(false)
    const isRoomTransitioning = ref<boolean>(false)
    const activeInfoHotspot = ref<RoomHotspot | null>(null)
    const hoveredHotspotId = ref<string | null>(null)
    
    // Current Room computed
    const currentRoom = computed(() => {
      return allRooms.value.find((r) => r.id === currentRoomId.value) || allRooms.value[0]
    })
    
    // Normalized Compass Heading (0 to 360)
    const compassHeading = computed(() => {
      const normalized = ((yaw.value % 360) + 360) % 360
      return Math.round(normalized)
    })
    
    const compassDirection = computed(() => {
      const h = compassHeading.value
      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'
    })
    
    // Auto-rotation loop
    let autoRotateRaf: number | null = null
    
    function updateAutoRotate() {
      if (isAutoRotating.value && !isDragging.value) {
        yaw.value = (yaw.value + 0.18) % 360
      }
      autoRotateRaf = requestAnimationFrame(updateAutoRotate)
    }
    
    onMounted(() => {
      autoRotateRaf = requestAnimationFrame(updateAutoRotate)
      window.addEventListener('keydown', handleKeydown)
    })
    
    onUnmounted(() => {
      if (autoRotateRaf) cancelAnimationFrame(autoRotateRaf)
      window.removeEventListener('keydown', handleKeydown)
    })
    
    // Room transition
    function switchRoom(roomId: string) {
      if (roomId === currentRoomId.value) return
      isRoomTransitioning.value = true
      activeInfoHotspot.value = null
    
      setTimeout(() => {
        currentRoomId.value = roomId
        yaw.value = 0
        pitch.value = 0
        setTimeout(() => {
          isRoomTransitioning.value = false
        }, 280)
      }, 220)
    }
    
    // Mouse / Touch Interaction Handlers
    function onPointerDown(e: MouseEvent | TouchEvent) {
      isDragging.value = true
      const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
      const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
      dragStartX.value = clientX
      dragStartY.value = clientY
      startYaw.value = yaw.value
      startPitch.value = pitch.value
    }
    
    function onPointerMove(e: MouseEvent | TouchEvent) {
      if (!isDragging.value) 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 - dragStartX.value
      const deltaY = clientY - dragStartY.value
    
      yaw.value = (startYaw.value - deltaX * 0.25 + 3600) % 360
      pitch.value = Math.max(-22, Math.min(22, startPitch.value + deltaY * 0.18))
    }
    
    function onPointerUp() {
      isDragging.value = false
    }
    
    // Pan step helpers
    function panStep(dx: number, dy: number) {
      yaw.value = (yaw.value + dx + 3600) % 360
      pitch.value = Math.max(-22, Math.min(22, pitch.value + dy))
    }
    
    function zoomStep(delta: number) {
      zoom.value = Math.max(0.85, Math.min(1.5, zoom.value + delta))
    }
    
    function resetView() {
      yaw.value = 0
      pitch.value = 0
      zoom.value = 1.0
    }
    
    function 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') isMinimapOpen.value = !isMinimapOpen.value
      if (e.key === 'r' || e.key === 'R') isMeasurementMode.value = !isMeasurementMode.value
    }
    
    function copyTourShareLink() {
      copiedToast.value = true
      if (typeof navigator !== 'undefined' && navigator.clipboard) {
        navigator.clipboard.writeText('https://uipkge.dev/tour/742-evergreen-terrace')
      }
      setTimeout(() => {
        copiedToast.value = false
      }, 3200)
    }
    
    function toggleFullscreen() {
      isFullscreen.value = !isFullscreen.value
    }
    
    function getHotspotStyle(hotspot: RoomHotspot) {
      const relativeX = Math.max(8, Math.min(92, (hotspot.x - (yaw.value / 360) * 100 + 150) % 100))
      const relativeY = Math.max(8, Math.min(88, hotspot.y + pitch.value * 0.4))
      return {
        left: `${relativeX}%`,
        top: `${relativeY}%`,
      }
    }
    
    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
      }
    }
    </script>
    
    <template>
      <div
        data-slot="virtual-tour-panorama"
        :class="[
          '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 class="bg-card rounded-xl border p-3.5 shadow-xs sm:px-5 sm:py-3">
          <div class="flex flex-wrap items-center justify-between gap-3">
            <!-- Property Identity & Room Badge -->
            <div class="flex flex-wrap items-center gap-2.5 sm:gap-3">
              <div class="flex items-center gap-2">
                <div
                  class="bg-primary/10 text-primary border-primary/30 flex size-8 items-center justify-center rounded-lg border shadow-xs"
                >
                  <Compass class="size-4 animate-[spin_12s_linear_infinite]" />
                </div>
                <div>
                  <div class="flex items-center gap-2">
                    <h2 class="text-foreground text-sm font-bold tracking-tight sm:text-base">742 Evergreen Terrace</h2>
                    <span class="text-muted-foreground hidden text-xs font-medium sm:inline">360° Virtual Tour</span>
                  </div>
                  <p class="text-muted-foreground text-xs">Penthouse Residence 14B · The Evergreen Collection</p>
                </div>
              </div>
    
              <Separator orientation="vertical" class="hidden h-6 sm:block" />
    
              <!-- Dynamic Active Room Badge -->
              <Badge
                variant="outline"
                class="border-primary/40 bg-primary/10 text-primary gap-1.5 px-2.5 py-1 text-xs font-semibold"
              >
                <component :is="getRoomIcon(currentRoom.id)" class="size-3.5" />
                <span>{{ currentRoom.badgeName }}</span>
              </Badge>
    
              <!-- Resolution & Live Feed Indicator -->
              <div
                class="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 class="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 class="flex items-center gap-2">
              <!-- Ambience Audio Toggle -->
              <Button
                variant="outline"
                size="sm"
                :class="['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'"
                @click="isAudioActive = !isAudioActive"
              >
                <Volume2 v-if="isAudioActive" class="text-info size-3.5" />
                <VolumeX v-else class="text-muted-foreground size-3.5" />
                <span class="hidden md:inline">{{ isAudioActive ? 'Sound On' : 'Ambience' }}</span>
              </Button>
    
              <!-- Gyroscope / VR Mode Toggle -->
              <Button
                variant="outline"
                size="sm"
                :class="['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'"
                @click="isVrMode = !isVrMode"
              >
                <Glasses class="size-3.5" />
                <span class="hidden sm:inline">{{ isVrMode ? 'VR Active' : 'VR Mode' }}</span>
              </Button>
    
              <!-- Share Tour Link Button -->
              <Button
                variant="outline"
                size="sm"
                class="relative h-8 gap-1.5 px-2.5 text-xs"
                aria-label="Share 360 Tour"
                @click="copyTourShareLink"
              >
                <Check v-if="copiedToast" class="text-success size-3.5" />
                <Share2 v-else class="size-3.5" />
                <span class="hidden sm:inline">{{ copiedToast ? 'Copied!' : 'Share' }}</span>
              </Button>
    
              <!-- Fullscreen Toggle -->
              <Button
                variant="outline"
                size="icon"
                class="size-8"
                :aria-label="isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'"
                @click="toggleFullscreen"
              >
                <Minimize2 v-if="isFullscreen" class="size-3.5" />
                <Maximize2 v-else class="size-3.5" />
              </Button>
            </div>
          </div>
        </header>
    
        <!-- 360 Panorama Viewport Container -->
        <div
          class="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]"
          @mousedown="onPointerDown"
          @mousemove="onPointerMove"
          @mouseup="onPointerUp"
          @mouseleave="onPointerUp"
          @touchstart="onPointerDown"
          @touchmove="onPointerMove"
          @touchend="onPointerUp"
        >
          <TourPanoramaScene
            :current-room="currentRoom"
            :yaw="yaw"
            :pitch="pitch"
            :zoom="zoom"
            :is-room-transitioning="isRoomTransitioning"
            :is-measurement-mode="isMeasurementMode"
            :measurement-unit="measurementUnit"
          />
    
          <!-- Interactive Hotspot Pins Layer -->
          <div class="pointer-events-auto absolute inset-0 size-full">
            <div
              v-for="hotspot in currentRoom.hotspots"
              :key="hotspot.id"
              class="absolute -translate-x-1/2 -translate-y-1/2 transition-transform duration-100"
              :style="getHotspotStyle(hotspot)"
              @mouseenter="hoveredHotspotId = hotspot.id"
              @mouseleave="hoveredHotspotId = null"
            >
              <!-- PORTAL HOTSPOT: Room Navigation -->
              <div v-if="hotspot.type === 'portal'" class="group relative flex flex-col items-center">
                <span class="bg-success absolute -inset-2.5 rounded-full opacity-60 duration-1000" />
                <span class="bg-success/40 absolute -inset-1 rounded-full opacity-80 blur-xs" />
    
                <button
                  type="button"
                  class="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"
                  @click.stop="hotspot.targetRoomId && switchRoom(hotspot.targetRoomId)"
                >
                  <Navigation class="size-4" />
                </button>
    
                <!-- Floating Label Tooltip (Portal) -->
                <div
                  :class="[
                    '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 class="text-success flex items-center gap-1 text-xs font-bold">
                    <span>{{ hotspot.title }}</span>
                  </div>
                  <p class="text-muted-foreground mt-0.5 text-xs">
                    {{ hotspot.subtitle }}
                  </p>
                  <span class="text-success mt-1 inline-flex items-center gap-1 font-mono text-xs font-semibold">
                    Click to Transition <ArrowRight class="size-3" />
                  </span>
                </div>
              </div>
    
              <!-- INFO HOTSPOT: Feature Inspection -->
              <div v-else-if="hotspot.type === 'info'" class="group relative flex flex-col items-center">
                <span class="bg-info absolute -inset-2.5 rounded-full opacity-60 duration-1000" />
                <span class="bg-info/40 absolute -inset-1 rounded-full opacity-80 blur-xs" />
    
                <button
                  type="button"
                  class="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"
                  @click.stop="activeInfoHotspot = hotspot"
                >
                  <Info class="size-4" />
                </button>
    
                <!-- Floating Label Tooltip (Info) -->
                <div
                  :class="[
                    '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 class="text-info flex items-center gap-1 text-xs font-bold">
                    <Info class="size-3" />
                    <span>{{ hotspot.title }}</span>
                  </div>
                  <p class="text-muted-foreground mt-0.5 text-xs">
                    {{ hotspot.subtitle }}
                  </p>
                  <span class="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
            class="pointer-events-none absolute inset-x-3.5 top-3.5 z-20 flex flex-wrap items-start justify-between gap-2"
          >
            <!-- Compass & Angle HUD Indicator -->
            <div
              class="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 class="flex items-center gap-1.5 font-mono text-xs font-bold text-zinc-100">
                <Compass
                  class="text-primary size-3.5 transition-transform duration-100"
                  :style="{ transform: `rotate(${compassHeading}deg)` }"
                />
                <span>{{ compassHeading }}° {{ compassDirection }}</span>
              </div>
              <Separator orientation="vertical" class="h-3.5 bg-zinc-700" />
              <span class="text-muted-foreground font-mono text-xs">FOV 85°</span>
              <Separator orientation="vertical" class="h-3.5 bg-zinc-700" />
              <span class="text-muted-foreground font-mono text-xs"
                >Pitch {{ pitch > 0 ? `+${Math.round(pitch)}°` : `${Math.round(pitch)}°` }}</span
              >
            </div>
    
            <!-- Canvas Top Right Action Badges -->
            <div class="pointer-events-auto flex items-center gap-2">
              <div
                v-if="isVrMode"
                class="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 class="size-3.5 animate-pulse" />
                <span>Gyroscope / VR Sensor Active</span>
              </div>
    
              <div
                v-if="isMeasurementMode"
                class="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'"
                  class="h-6 px-2 text-xs"
                  @click="measurementUnit = 'imperial'"
                >
                  Imperial (ft)
                </Button>
                <Button
                  size="sm"
                  :variant="measurementUnit === 'metric' ? 'destructive' : 'ghost'"
                  class="h-6 px-2 text-xs"
                  @click="measurementUnit = 'metric'"
                >
                  Metric (m)
                </Button>
              </div>
            </div>
          </div>
    
          <!-- Floating 360 Pan & Zoom Controls (Center-Right overlay) -->
          <div
            class="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"
              class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
              aria-label="Pan Up"
              @click="panStep(0, 8)"
            >
              <ChevronUp class="size-4" />
            </Button>
            <div class="flex items-center gap-1">
              <Button
                variant="ghost"
                size="icon"
                class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                aria-label="Pan Left"
                @click="panStep(-15, 0)"
              >
                <ChevronLeft class="size-4" />
              </Button>
              <Button
                variant="ghost"
                size="icon"
                class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                aria-label="Reset View"
                @click="resetView"
              >
                <RotateCcw class="size-3.5" />
              </Button>
              <Button
                variant="ghost"
                size="icon"
                class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                aria-label="Pan Right"
                @click="panStep(15, 0)"
              >
                <ChevronRight class="size-4" />
              </Button>
            </div>
            <Button
              variant="ghost"
              size="icon"
              class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
              aria-label="Pan Down"
              @click="panStep(0, -8)"
            >
              <ChevronDown class="size-4" />
            </Button>
            <Separator class="my-0.5 bg-zinc-800" />
            <Button
              variant="ghost"
              size="icon"
              class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
              aria-label="Zoom In"
              @click="zoomStep(0.15)"
            >
              <ZoomIn class="size-3.5" />
            </Button>
            <Button
              variant="ghost"
              size="icon"
              class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
              aria-label="Zoom Out"
              @click="zoomStep(-0.15)"
            >
              <ZoomOut class="size-3.5" />
            </Button>
          </div>
    
          <!-- 2D Floor Plan Minimap Overlay (Top-Left corner dock) -->
          <TourFloorPlanNavigator
            v-if="isMinimapOpen"
            :rooms="allRooms"
            :current-room-id="currentRoomId"
            :current-room="currentRoom"
            :yaw="yaw"
            @select-room="switchRoom"
            @close="isMinimapOpen = false"
          />
    
          <!-- Feature Inspection Floating Detail Card Modal -->
          <TourHotspotModal v-if="activeInfoHotspot" :hotspot="activeInfoHotspot" @close="activeInfoHotspot = null" />
    
          <!-- Bottom Floating Navigation Dock (Room Switcher & Tool Controls) -->
          <div class="pointer-events-auto absolute inset-x-3.5 bottom-3.5 z-30 flex items-center justify-center">
            <div
              class="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"
            >
              <!-- Auto-Rotate 360 Toggle -->
              <Button
                :variant="isAutoRotating ? 'default' : 'secondary'"
                size="sm"
                class="h-8 gap-1.5 rounded-full px-3 text-xs font-medium"
                :aria-label="isAutoRotating ? 'Pause 360 auto rotate' : 'Play 360 auto rotate'"
                @click="isAutoRotating = !isAutoRotating"
              >
                <Pause v-if="isAutoRotating" class="size-3.5" />
                <Play v-else class="size-3.5" />
                <span class="hidden sm:inline">{{ isAutoRotating ? 'Pause Tour' : 'Auto Tour' }}</span>
              </Button>
    
              <Separator orientation="vertical" class="hidden h-5 bg-zinc-800 sm:block" />
    
              <!-- Room Switcher Thumbnails Carousel -->
              <div class="flex items-center gap-1.5 overflow-x-auto py-0.5">
                <button
                  v-for="r in allRooms"
                  :key="r.id"
                  type="button"
                  :class="[
                    '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',
                  ]"
                  @click="switchRoom(r.id)"
                >
                  <component :is="getRoomIcon(r.id)" class="size-3.5" />
                  <span class="whitespace-nowrap">{{ r.name }}</span>
                </button>
              </div>
    
              <Separator orientation="vertical" class="hidden h-5 bg-zinc-800 sm:block" />
    
              <!-- Minimap Toggle Button -->
              <Button
                :variant="isMinimapOpen ? 'default' : 'secondary'"
                size="sm"
                class="h-8 gap-1.5 rounded-full px-2.5 text-xs"
                :aria-label="isMinimapOpen ? 'Hide floor plan minimap' : 'Show floor plan minimap'"
                @click="isMinimapOpen = !isMinimapOpen"
              >
                <MapIcon class="size-3.5" />
                <span class="hidden md:inline">Minimap</span>
              </Button>
    
              <!-- Laser Measurement Toggle Button -->
              <Button
                :variant="isMeasurementMode ? 'destructive' : 'secondary'"
                size="sm"
                class="h-8 gap-1.5 rounded-full px-2.5 text-xs"
                :aria-label="isMeasurementMode ? 'Turn off measurement mode' : 'Turn on measurement mode'"
                @click="isMeasurementMode = !isMeasurementMode"
              >
                <Ruler class="size-3.5" />
                <span class="hidden md:inline">Measure</span>
              </Button>
            </div>
          </div>
        </div>
    
        <!-- Architectural Deep Dive Specs & Floor Schedule Cards -->
        <TourRoomDetails :current-room="currentRoom" />
      </div>
    </template>
    
  • app/components/blocks/virtual-tour-panorama/TourFloorPlanNavigator.vue4 kB
  • app/components/blocks/virtual-tour-panorama/TourHotspotModal.vue2.4 kB
  • app/components/blocks/virtual-tour-panorama/TourPanoramaScene.vue11.6 kB
  • app/components/blocks/virtual-tour-panorama/TourRoomDetails.vue6.1 kB
  • app/components/blocks/virtual-tour-panorama/virtual-tour-panorama-data.ts18.4 kB
  • app/components/blocks/virtual-tour-panorama/virtual-tour-panorama-types.ts0.9 kB

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