Menu

Change language

GitHub 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.

Installation

$npx uipkge-ng@latest add virtual-tour-panorama
Manifest:https://uipkge.dev/r/angular/virtual-tour-panorama.jsonInstalls to:src/app/components/blocks/virtual-tour-panorama/

Variants

Loading interactive previews…

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 (4)

  • src/app/components/blocks/virtual-tour-panorama/virtual-tour-panorama.component.ts54.8 kB
    // 360-degree immersive room virtual tour panorama viewer: interactive viewpoint
    // hotspots, laser measurement overlays, architectural floor plan minimap with FOV
    // radar cone, compass HUD, pan/zoom controls, and deep-dive room spec cards.
    // Port of the React block 1:1 (sub-views consolidated into one standalone component).
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, effect, inject, signal } from '@angular/core'
    import {
      ArrowRight,
      Bath,
      Bed,
      Calendar,
      Check,
      CheckCircle2,
      ChevronDown,
      ChevronLeft,
      ChevronRight,
      ChevronUp,
      Compass,
      Glasses,
      Home,
      Info,
      LucideAngularModule,
      Map as MapIcon,
      Maximize2,
      Minimize2,
      Navigation,
      Pause,
      Play,
      RotateCcw,
      Ruler,
      Share2,
      ShieldCheck,
      Sofa,
      Sun,
      Utensils,
      Volume2,
      VolumeX,
      X,
      ZoomIn,
      ZoomOut,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { defaultRooms } from './virtual-tour-panorama-data'
    import type { RoomData, RoomHotspot } from './virtual-tour-panorama-types'
    
    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
      }
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-virtual-tour-panorama, [ui-virtual-tour-panorama]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="virtual-tour-panorama" [class]="rootClass">
          <!-- 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"
                  >
                    <lucide-icon [img]="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>
    
                <ui-separator orientation="vertical" class="hidden h-6 sm:block" />
    
                <!-- Dynamic Active Room Badge -->
                <span
                  ui-badge
                  variant="outline"
                  class="border-primary/40 bg-primary/10 text-primary gap-1.5 px-2.5 py-1 text-xs font-semibold"
                >
                  <lucide-icon [img]="currentRoomIcon" class="size-3.5" />
                  <span>{{ currentRoom().badgeName }}</span>
                </span>
    
                <!-- 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>
                  <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">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  [class]="ambienceClass"
                  [attr.aria-label]="isAudioActive() ? 'Mute ambient soundscape' : 'Play ambient soundscape'"
                  (click)="isAudioActive.set(!isAudioActive())"
                >
                  @if (isAudioActive()) {
                    <lucide-icon [img]="Volume2" class="text-info size-3.5" />
                  } @else {
                    <lucide-icon [img]="VolumeX" class="text-muted-foreground size-3.5" />
                  }
                  <span class="hidden md:inline">{{ isAudioActive() ? 'Sound On' : 'Ambience' }}</span>
                </button>
    
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  [class]="vrClass"
                  [attr.aria-label]="isVrMode() ? 'Exit VR mode' : 'Enter VR mode'"
                  (click)="isVrMode.set(!isVrMode())"
                >
                  <lucide-icon [img]="Glasses" class="size-3.5" />
                  <span class="hidden sm:inline">{{ isVrMode() ? 'VR Active' : 'VR Mode' }}</span>
                </button>
    
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  class="relative h-8 gap-1.5 px-2.5 text-xs"
                  aria-label="Share 360 Tour"
                  (click)="copyTourShareLink()"
                >
                  @if (copiedToast()) {
                    <lucide-icon [img]="Check" class="text-success size-3.5" />
                  } @else {
                    <lucide-icon [img]="Share2" class="size-3.5" />
                  }
                  <span class="hidden sm:inline">{{ copiedToast() ? 'Copied!' : 'Share' }}</span>
                </button>
    
                <button
                  ui-button
                  variant="outline"
                  size="icon"
                  class="size-8"
                  [attr.aria-label]="isFullscreen() ? 'Exit Fullscreen' : 'Enter Fullscreen'"
                  (click)="toggleFullscreen()"
                >
                  @if (isFullscreen()) {
                    <lucide-icon [img]="Minimize2" class="size-3.5" />
                  } @else {
                    <lucide-icon [img]="Maximize2" 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($event)"
            (mousemove)="onPointerMove($event)"
            (mouseup)="onPointerUp()"
            (mouseleave)="onPointerUp()"
            (touchstart)="onPointerDown($event)"
            (touchmove)="onPointerMove($event)"
            (touchend)="onPointerUp()"
          >
            <!-- Panorama Scene -->
            <div
              class="absolute inset-0 size-full transition-colors duration-300 ease-out"
              [class]="isRoomTransitioning() ? 'scale-105 opacity-20 blur-md' : 'opacity-100 blur-none'"
              [style.transform]="'scale(' + zoom() + ')'"
              style="transform-origin: center center;"
            >
              <!-- Background Gradient Ambience Layer -->
              <div [class]="'absolute inset-0 bg-gradient-to-b ' + currentRoom().skylineStyle"></div>
    
              <!-- Architectural Wireframe / Perspective Room Illustration -->
              <svg
                class="absolute inset-0 size-full"
                preserveAspectRatio="none"
                viewBox="0 0 1000 600"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
              >
                <defs>
                  <radialGradient id="sunGlowNg" cx="50%" cy="40%" r="50%">
                    <stop offset="0%" stopColor="#fbbf24" stopOpacity="0.35"></stop>
                    <stop offset="60%" stopColor="#f43f5e" stopOpacity="0.15"></stop>
                    <stop offset="100%" stopColor="#000000" stopOpacity="0"></stop>
                  </radialGradient>
                  <linearGradient id="floorGradNg" x1="50%" y1="0%" x2="50%" y2="100%">
                    <stop offset="0%" stopColor="#0f172a" stopOpacity="0.9"></stop>
                    <stop offset="40%" stopColor="#1e293b" stopOpacity="0.95"></stop>
                    <stop offset="100%" stopColor="#020617" stopOpacity="1"></stop>
                  </linearGradient>
                  <linearGradient id="ceilingGradNg" x1="50%" y1="0%" x2="50%" y2="100%">
                    <stop offset="0%" stopColor="#020617"></stop>
                    <stop offset="100%" stopColor="#0f172a" stopOpacity="0.7"></stop>
                  </linearGradient>
                </defs>
    
                <!-- Panoramic Window Skyline & Perspective Architecture (Parallax shifting with yaw) -->
                <g [attr.transform]="'translate(' + parallaxX() + ', ' + parallaxY() + ')'">
                  <!-- Horizon Skybox Glow -->
                  <rect x="-300" y="80" width="1600" height="340" fill="url(#sunGlowNg)"></rect>
    
                  <!-- Skyline Cityscape Silhouettes -->
                  <path
                    d="M-200 320 L-180 260 L-150 260 L-150 320 L-120 320 L-110 220 L-80 220 L-70 320 L-40 320 L-30 200 L0 200 L10 320 L50 320 L60 170 L90 170 L100 320 L150 320 L160 240 L190 240 L200 320 L260 320 L270 190 L310 190 L320 320 L380 320 L390 210 L430 210 L440 320 L500 320 L510 160 L540 160 L560 320 L620 320 L630 230 L670 230 L680 320 L740 320 L750 180 L790 180 L800 320 L860 320 L870 250 L910 250 L920 320 L980 320 L990 200 L1030 200 L1040 320 L1100 320 L1120 230 L1160 230 L1180 320 L1240 320 L1250 190 L1290 190 L1300 320 Z"
                    fill="#1e1b4b"
                    fillOpacity="0.4"
                  ></path>
                  <path
                    d="M-150 320 L-140 280 L-110 280 L-100 320 L-50 320 L-40 240 L-10 240 L0 320 L70 320 L80 220 L120 220 L130 320 L210 320 L220 250 L250 250 L260 320 L340 320 L350 210 L380 210 L390 320 L460 320 L470 240 L500 240 L510 320 L580 320 L590 200 L630 200 L640 320 L710 320 L720 260 L750 260 L760 320 L830 320 L840 220 L880 220 L890 320 L960 320 L970 260 L1000 260 L1010 320 L1080 320 L1090 220 L1130 220 L1140 320 Z"
                    fill="#0f172a"
                    fillOpacity="0.65"
                  ></path>
    
                  <!-- Architectural Window Mullions -->
                  <line x1="0" y1="90" x2="0" y2="400" stroke="#334155" strokeWidth="6"></line>
                  <line x1="250" y1="90" x2="250" y2="400" stroke="#334155" strokeWidth="6"></line>
                  <line x1="500" y1="90" x2="500" y2="400" stroke="#475569" strokeWidth="8"></line>
                  <line x1="750" y1="90" x2="750" y2="400" stroke="#334155" strokeWidth="6"></line>
                  <line x1="1000" y1="90" x2="1000" y2="400" stroke="#334155" strokeWidth="6"></line>
                  <line x1="1250" y1="90" x2="1250" y2="400" stroke="#334155" strokeWidth="6"></line>
                  <line x1="-300" y1="90" x2="1300" y2="90" stroke="#334155" strokeWidth="6"></line>
                  <line x1="-300" y1="400" x2="1300" y2="400" stroke="#334155" strokeWidth="8"></line>
    
                  <!-- Room-Specific Detailed Focal Art Piece -->
                  @if (currentRoom().id === 'master-bedroom') {
                    <g>
                      <rect
                        x="360"
                        y="310"
                        width="280"
                        height="90"
                        rx="4"
                        fill="#1e293b"
                        stroke="#475569"
                        strokeWidth="2"
                      ></rect>
                      <rect
                        x="380"
                        y="240"
                        width="240"
                        height="70"
                        rx="6"
                        fill="#334155"
                        stroke="#64748b"
                        strokeWidth="2"
                      ></rect>
                      <line x1="400" y1="240" x2="400" y2="310" stroke="#475569" strokeWidth="1.5"></line>
                      <line x1="460" y1="240" x2="460" y2="310" stroke="#475569" strokeWidth="1.5"></line>
                      <line x1="520" y1="240" x2="520" y2="310" stroke="#475569" strokeWidth="1.5"></line>
                      <line x1="580" y1="240" x2="580" y2="310" stroke="#475569" strokeWidth="1.5"></line>
                      <ellipse cx="430" cy="330" rx="35" ry="15" fill="#e2e8f0" fillOpacity="0.8"></ellipse>
                      <ellipse cx="570" cy="330" rx="35" ry="15" fill="#e2e8f0" fillOpacity="0.8"></ellipse>
                      <rect x="300" y="340" width="50" height="45" rx="3" fill="#0f172a" stroke="#334155"></rect>
                      <rect x="650" y="340" width="50" height="45" rx="3" fill="#0f172a" stroke="#334155"></rect>
                      <circle cx="325" cy="315" r="8" fill="#f59e0b" fillOpacity="0.6"></circle>
                      <circle cx="675" cy="315" r="8" fill="#f59e0b" fillOpacity="0.6"></circle>
                    </g>
                  }
    
                  @if (currentRoom().id === 'living-room') {
                    <g>
                      <path
                        d="M320 380 Q500 360 680 380 L660 430 Q500 410 340 430 Z"
                        fill="#334155"
                        stroke="#64748b"
                        strokeWidth="2"
                      ></path>
                      <rect
                        x="420"
                        y="400"
                        width="160"
                        height="40"
                        rx="8"
                        fill="#475569"
                        stroke="#94a3b8"
                        strokeWidth="1.5"
                      ></rect>
                      <rect
                        x="400"
                        y="270"
                        width="200"
                        height="35"
                        rx="4"
                        fill="#09090b"
                        stroke="#f59e0b"
                        strokeWidth="1.5"
                      ></rect>
                      <path
                        d="M420 292 Q430 280 440 292 Q450 276 460 292 Q470 282 480 292 Q490 278 500 292 Q510 280 520 292 Q530 276 540 292 Q550 280 560 292 Q570 278 580 292"
                        stroke="#f97316"
                        strokeWidth="3"
                        fill="none"
                      ></path>
                    </g>
                  }
    
                  @if (currentRoom().id === 'gourmet-kitchen') {
                    <g>
                      <path
                        d="M300 340 L700 340 L740 420 L260 420 Z"
                        fill="#0f172a"
                        stroke="#38bdf8"
                        strokeWidth="2"
                        strokeDasharray="400 4"
                      ></path>
                      <rect x="360" y="325" width="280" height="15" rx="3" fill="#1e293b" stroke="#64748b"></rect>
                      <circle cx="340" cy="425" r="14" fill="#334155" stroke="#94a3b8"></circle>
                      <circle cx="450" cy="425" r="14" fill="#334155" stroke="#94a3b8"></circle>
                      <circle cx="550" cy="425" r="14" fill="#334155" stroke="#94a3b8"></circle>
                      <circle cx="660" cy="425" r="14" fill="#334155" stroke="#94a3b8"></circle>
                    </g>
                  }
    
                  @if (currentRoom().id === 'rooftop-terrace') {
                    <g>
                      <rect
                        x="520"
                        y="340"
                        width="340"
                        height="90"
                        rx="8"
                        fill="#0369a1"
                        stroke="#38bdf8"
                        strokeWidth="3"
                      ></rect>
                      <path
                        d="M540 370 Q600 360 660 370 Q720 380 780 370 Q820 360 840 370"
                        stroke="#7dd3fc"
                        strokeWidth="2"
                        fill="none"
                      ></path>
                      <path
                        d="M540 395 Q600 385 660 395 Q720 405 780 395 Q820 385 840 395"
                        stroke="#7dd3fc"
                        strokeWidth="2"
                        fill="none"
                      ></path>
                      <rect
                        x="220"
                        y="360"
                        width="100"
                        height="40"
                        rx="4"
                        fill="#78350f"
                        stroke="#b45309"
                        strokeWidth="1.5"
                      ></rect>
                      <rect
                        x="350"
                        y="360"
                        width="100"
                        height="40"
                        rx="4"
                        fill="#78350f"
                        stroke="#b45309"
                        strokeWidth="1.5"
                      ></rect>
                    </g>
                  }
    
                  @if (currentRoom().id === 'spa-bath') {
                    <g>
                      <ellipse cx="500" cy="380" rx="140" ry="45" fill="#f8fafc" stroke="#cbd5e1" strokeWidth="3"></ellipse>
                      <ellipse cx="500" cy="380" rx="115" ry="32" fill="#0284c7" fillOpacity="0.4"></ellipse>
                      <circle cx="340" cy="240" r="45" fill="#1e293b" stroke="#f59e0b" strokeWidth="2"></circle>
                      <circle cx="660" cy="240" r="45" fill="#1e293b" stroke="#f59e0b" strokeWidth="2"></circle>
                    </g>
                  }
                </g>
    
                <!-- Perspective Floor Grid -->
                <path
                  d="M-200 600 L300 400 M0 600 L400 400 M200 600 L500 400 M400 600 L500 400 M600 600 L500 400 M800 600 L600 400 M1000 600 L700 400 M1200 600 L800 400"
                  stroke="#334155"
                  strokeOpacity="0.25"
                  strokeWidth="1.5"
                ></path>
                <line x1="-200" y1="440" x2="1200" y2="440" stroke="#334155" strokeOpacity="0.25" strokeWidth="1.5"></line>
                <line x1="-200" y1="490" x2="1200" y2="490" stroke="#334155" strokeOpacity="0.25" strokeWidth="1.5"></line>
                <line x1="-200" y1="545" x2="1200" y2="545" stroke="#334155" strokeOpacity="0.25" strokeWidth="1.5"></line>
    
                <!-- Ceiling Cove Recessed Glow line -->
                <line x1="0" y1="40" x2="1000" y2="40" stroke="#fbbf24" strokeOpacity="0.3" strokeWidth="2"></line>
              </svg>
    
              <!-- Dynamic Laser Measurement Overlay (When enabled) -->
              @if (isMeasurementMode()) {
                <svg
                  class="pointer-events-none absolute inset-0 size-full transition-opacity duration-300"
                  preserveAspectRatio="none"
                  viewBox="0 0 100 100"
                >
                  @for (m of currentRoom().measurements; track m.id) {
                    <g>
                      <line
                        [attr.x1]="m.x1"
                        [attr.y1]="m.y1"
                        [attr.x2]="m.x2"
                        [attr.y2]="m.y2"
                        stroke="#f43f5e"
                        strokeWidth="0.75"
                        strokeDasharray="2 1.5"
                      ></line>
                      <circle [attr.cx]="m.x1" [attr.cy]="m.y1" r="1.5" fill="#f43f5e"></circle>
                      <circle [attr.cx]="m.x2" [attr.cy]="m.y2" r="1.5" fill="#f43f5e"></circle>
                    </g>
                  }
                </svg>
              }
    
              <!-- Measurement Pill Badges (HTML Overlay) -->
              @if (isMeasurementMode()) {
                <div class="pointer-events-none absolute inset-0 size-full">
                  @for (m of currentRoom().measurements; track 'badge-' + m.id) {
                    <div
                      class="absolute -translate-x-1/2 -translate-y-1/2"
                      [style.left]="m.labelX + '%'"
                      [style.top]="m.labelY + '%'"
                    >
                      <div
                        class="border-destructive/80 text-destructive flex items-center gap-1.5 rounded-md border bg-zinc-950/90 px-2 py-0.5 font-mono text-xs font-bold shadow-xl backdrop-blur-md"
                      >
                        <lucide-icon [img]="Ruler" class="size-3" />
                        <span>{{ m.title }}:</span>
                        <span class="text-white">{{ measurementUnit() === 'imperial' ? m.imperial : m.metric }}</span>
                      </div>
                    </div>
                  }
                </div>
              }
            </div>
    
            <!-- Interactive Hotspot Pins Layer -->
            <div class="pointer-events-auto absolute inset-0 size-full">
              @for (hotspot of currentRoom().hotspots; track hotspot.id) {
                <div
                  class="absolute -translate-x-1/2 -translate-y-1/2 transition-transform duration-100"
                  [style.left]="hotspotLeft(hotspot) + '%'"
                  [style.top]="hotspotTop(hotspot) + '%'"
                  (mouseenter)="hoveredHotspotId.set(hotspot.id)"
                  (mouseleave)="hoveredHotspotId.set(null)"
                >
                  <!-- PORTAL HOTSPOT: Room Navigation -->
                  @if (hotspot.type === 'portal') {
                    <div class="group relative flex flex-col items-center">
                      <span class="bg-success absolute -inset-2.5 rounded-full opacity-60 duration-1000"></span>
                      <span class="bg-success/40 absolute -inset-1 rounded-full opacity-80 blur-xs"></span>
    
                      <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"
                        [attr.aria-label]="hotspot.title"
                        (click)="navigateHotspot(hotspot, $event)"
                      >
                        <lucide-icon [img]="Navigation" class="size-4" />
                      </button>
    
                      <div [class]="hotspotTipClass(hotspot, 'border-success/40')">
                        <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 <lucide-icon [img]="ArrowRight" class="size-3" />
                        </span>
                      </div>
                    </div>
                  }
    
                  <!-- INFO HOTSPOT: Feature Inspection -->
                  @if (hotspot.type === 'info') {
                    <div class="group relative flex flex-col items-center">
                      <span class="bg-info absolute -inset-2.5 rounded-full opacity-60 duration-1000"></span>
                      <span class="bg-info/40 absolute -inset-1 rounded-full opacity-80 blur-xs"></span>
    
                      <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"
                        [attr.aria-label]="hotspot.title"
                        (click)="inspectHotspot(hotspot, $event)"
                      >
                        <lucide-icon [img]="Info" class="size-4" />
                      </button>
    
                      <div [class]="hotspotTipClass(hotspot, 'border-info/40')">
                        <div class="text-info flex items-center gap-1 text-xs font-bold">
                          <lucide-icon [img]="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"
            >
              <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">
                  <lucide-icon
                    [img]="Compass"
                    class="text-primary size-3.5 transition-transform duration-100"
                    [style.transform]="'rotate(' + compassHeading() + 'deg)'"
                  />
                  <span>{{ compassHeading() }}° {{ compassDirection() }}</span>
                </div>
                <ui-separator orientation="vertical" class="h-3.5 bg-zinc-700" />
                <span class="text-muted-foreground font-mono text-xs">FOV 85°</span>
                <ui-separator orientation="vertical" class="h-3.5 bg-zinc-700" />
                <span class="text-muted-foreground font-mono text-xs">Pitch {{ pitchLabel }}</span>
              </div>
    
              <div class="pointer-events-auto flex items-center gap-2">
                @if (isVrMode()) {
                  <div
                    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"
                  >
                    <lucide-icon [img]="Glasses" class="size-3.5 animate-pulse" />
                    <span>Gyroscope / VR Sensor Active</span>
                  </div>
                }
    
                @if (isMeasurementMode()) {
                  <div
                    class="border-destructive/50 flex items-center gap-1 rounded-lg border bg-zinc-900/90 p-1 backdrop-blur-md"
                  >
                    <button
                      ui-button
                      size="sm"
                      [variant]="measurementUnit() === 'imperial' ? 'destructive' : 'ghost'"
                      class="h-6 px-2 text-xs"
                      (click)="measurementUnit.set('imperial')"
                    >
                      Imperial (ft)
                    </button>
                    <button
                      ui-button
                      size="sm"
                      [variant]="measurementUnit() === 'metric' ? 'destructive' : 'ghost'"
                      class="h-6 px-2 text-xs"
                      (click)="measurementUnit.set('metric')"
                    >
                      Metric (m)
                    </button>
                  </div>
                }
              </div>
            </div>
    
            <!-- Floating 360 Pan & Zoom Controls -->
            <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
                ui-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)"
              >
                <lucide-icon [img]="ChevronUp" class="size-4" />
              </button>
              <div class="flex items-center gap-1">
                <button
                  ui-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)"
                >
                  <lucide-icon [img]="ChevronLeft" class="size-4" />
                </button>
                <button
                  ui-button
                  variant="ghost"
                  size="icon"
                  class="size-7 text-zinc-300 hover:bg-zinc-800 hover:text-white"
                  aria-label="Reset View"
                  (click)="resetView()"
                >
                  <lucide-icon [img]="RotateCcw" class="size-3.5" />
                </button>
                <button
                  ui-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)"
                >
                  <lucide-icon [img]="ChevronRight" class="size-4" />
                </button>
              </div>
              <button
                ui-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)"
              >
                <lucide-icon [img]="ChevronDown" class="size-4" />
              </button>
              <ui-separator class="my-0.5 bg-zinc-800" />
              <button
                ui-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)"
              >
                <lucide-icon [img]="ZoomIn" class="size-3.5" />
              </button>
              <button
                ui-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)"
              >
                <lucide-icon [img]="ZoomOut" class="size-3.5" />
              </button>
            </div>
    
            <!-- 2D Floor Plan Minimap Overlay -->
            @if (isMinimapOpen()) {
              <div
                class="pointer-events-auto absolute top-14 left-3.5 z-30 w-56 overflow-hidden rounded-xl border border-zinc-700/80 bg-zinc-950/90 shadow-sm backdrop-blur-md transition-colors sm:w-64"
              >
                <div class="flex items-center justify-between border-b border-zinc-800 bg-zinc-900/90 px-3 py-1.5">
                  <div class="flex items-center gap-1.5">
                    <lucide-icon [img]="MapIcon" class="text-primary size-3.5" />
                    <span class="text-xs font-bold text-zinc-200">Floor Plan Minimap</span>
                  </div>
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground size-5 hover:text-white"
                    aria-label="Close minimap"
                    (click)="isMinimapOpen.set(false)"
                  >
                    <lucide-icon [img]="X" class="size-3" />
                  </button>
                </div>
    
                <div class="relative p-2.5">
                  <!-- 2D CAD Blueprint Floor Plan SVG -->
                  <svg class="w-full" viewBox="0 0 240 160" fill="none" xmlns="http://www.w3.org/2000/svg">
                    <defs>
                      <radialGradient id="fovGradientNg" cx="0" cy="0" r="100%" gradientUnits="userSpaceOnUse">
                        <stop offset="0%" stopColor="#10b981" stopOpacity="0.85"></stop>
                        <stop offset="60%" stopColor="#10b981" stopOpacity="0.35"></stop>
                        <stop offset="100%" stopColor="#10b981" stopOpacity="0.0"></stop>
                      </radialGradient>
                    </defs>
    
                    <!-- Floor Plan Room Outlines -->
                    @for (r of allRooms(); track 'map-r-' + r.id) {
                      <g class="cursor-pointer" (click)="switchRoom(r.id)">
                        <rect
                          [attr.x]="r.floorPlanCoords.x"
                          [attr.y]="r.floorPlanCoords.y"
                          [attr.width]="r.floorPlanCoords.w"
                          [attr.height]="r.floorPlanCoords.h"
                          [class]="minimapRoomClass(r.id)"
                          rx="3"
                        ></rect>
                      </g>
                    }
    
                    <!-- Dynamic Field of View (FOV) Radar Cone Indicator -->
                    <g [attr.transform]="fovTransform()">
                      <path d="M 0 0 L -22 -44 A 48 48 0 0 1 22 -44 Z" fill="url(#fovGradientNg)"></path>
                      <line
                        x1="0"
                        y1="0"
                        x2="-22"
                        y2="-44"
                        stroke="var(--success)"
                        strokeWidth="1"
                        strokeDasharray="2 2"
                      ></line>
                      <line
                        x1="0"
                        y1="0"
                        x2="22"
                        y2="-44"
                        stroke="var(--success)"
                        strokeWidth="1"
                        strokeDasharray="2 2"
                      ></line>
                    </g>
    
                    <!-- Active Viewpoint Marker with Pulsing Ping -->
                    <circle
                      [attr.cx]="currentRoom().floorPlanCoords.cx"
                      [attr.cy]="currentRoom().floorPlanCoords.cy"
                      r="4"
                      fill="var(--success)"
                      stroke="#ffffff"
                      strokeWidth="1.5"
                    ></circle>
    
                    <!-- Room Labels (painted last so the FOV cone and viewpoint marker never cover them) -->
                    @for (r of allRooms(); track 'map-l-' + r.id) {
                      <text
                        [attr.x]="r.floorPlanCoords.cx"
                        [attr.y]="r.floorPlanCoords.cy + 4"
                        textAnchor="middle"
                        [class]="minimapLabelClass(r.id)"
                        style="font-size: 9.5px;"
                      >
                        {{ r.floorPlanCoords.label }}
                      </text>
                    }
                  </svg>
    
                  <div class="text-muted-foreground mt-1 flex items-center justify-between text-xs">
                    <span class="font-mono">North ↑</span>
                    <span>Click room zone to jump</span>
                  </div>
                </div>
              </div>
            }
    
            <!-- Feature Inspection Floating Detail Card Modal -->
            @if (activeInfoHotspot(); as hs) {
              @if (hs.infoData; as info) {
                <div
                  class="border-info/40 pointer-events-auto absolute inset-x-4 top-14 z-40 mx-auto max-w-md rounded-xl border bg-zinc-950/95 p-4 shadow-sm backdrop-blur-xl sm:top-20"
                >
                  <div class="flex items-start justify-between gap-3 border-b border-zinc-800 pb-3">
                    <div class="space-y-1">
                      <div class="flex items-center gap-2">
                        <span ui-badge variant="outline" class="border-info/50 bg-info/10 text-info text-xs font-semibold">
                          {{ info.category }}
                        </span>
                        <span class="text-muted-foreground font-mono text-xs">{{ info.highlight }}</span>
                      </div>
                      <h3 class="text-sm font-bold text-white sm:text-base">{{ hs.title }}</h3>
                    </div>
                    <button
                      ui-button
                      variant="ghost"
                      size="icon"
                      class="text-muted-foreground size-7 hover:text-white"
                      aria-label="Close feature modal"
                      (click)="activeInfoHotspot.set(null)"
                    >
                      <lucide-icon [img]="X" class="size-4" />
                    </button>
                  </div>
    
                  <div class="space-y-3 pt-3 text-xs">
                    <p class="leading-relaxed text-zinc-300">{{ info.description }}</p>
    
                    <div class="space-y-1.5 rounded-lg border border-zinc-800 bg-zinc-900/60 p-2.5">
                      <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                        Architectural Specifications
                      </span>
                      <ul class="grid grid-cols-1 gap-1 text-zinc-200 sm:grid-cols-2">
                        @for (spec of info.specs; track spec) {
                          <li class="flex items-center gap-1.5">
                            <lucide-icon [img]="CheckCircle2" class="text-info size-3 shrink-0" />
                            <span class="truncate">{{ spec }}</span>
                          </li>
                        }
                      </ul>
                    </div>
    
                    <div
                      class="text-muted-foreground flex items-center justify-between border-t border-zinc-800 pt-2 text-xs"
                    >
                      <span>
                        Finishes: <strong class="text-zinc-200">{{ info.material }}</strong>
                      </span>
                      <button ui-button size="sm" class="h-7 text-xs" (click)="activeInfoHotspot.set(null)">Done</button>
                    </div>
                  </div>
                </div>
              }
            }
    
            <!-- Bottom Floating Navigation Dock -->
            <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"
              >
                <button
                  ui-button
                  [variant]="isAutoRotating() ? 'default' : 'secondary'"
                  size="sm"
                  class="h-8 gap-1.5 rounded-full px-3 text-xs font-medium"
                  [attr.aria-label]="isAutoRotating() ? 'Pause 360 auto rotate' : 'Play 360 auto rotate'"
                  (click)="toggleAutoRotate()"
                >
                  @if (isAutoRotating()) {
                    <lucide-icon [img]="Pause" class="size-3.5" />
                  } @else {
                    <lucide-icon [img]="Play" class="size-3.5" />
                  }
                  <span class="hidden sm:inline">{{ isAutoRotating() ? 'Pause Tour' : 'Auto Tour' }}</span>
                </button>
    
                <ui-separator orientation="vertical" class="hidden h-5 bg-zinc-800 sm:block" />
    
                <div class="flex items-center gap-1.5 overflow-x-auto py-0.5">
                  @for (r of allRooms(); track r.id) {
                    <button type="button" [class]="dockRoomClass(r.id)" (click)="switchRoom(r.id)">
                      <lucide-icon [img]="dockRoomIcon(r.id)" class="size-3.5" />
                      <span class="whitespace-nowrap">{{ r.name }}</span>
                    </button>
                  }
                </div>
    
                <ui-separator orientation="vertical" class="hidden h-5 bg-zinc-800 sm:block" />
    
                <button
                  ui-button
                  [variant]="isMinimapOpen() ? 'default' : 'secondary'"
                  size="sm"
                  class="h-8 gap-1.5 rounded-full px-2.5 text-xs"
                  [attr.aria-label]="isMinimapOpen() ? 'Hide floor plan minimap' : 'Show floor plan minimap'"
                  (click)="isMinimapOpen.set(!isMinimapOpen())"
                >
                  <lucide-icon [img]="MapIcon" class="size-3.5" />
                  <span class="hidden md:inline">Minimap</span>
                </button>
    
                <button
                  ui-button
                  [variant]="isMeasurementMode() ? 'destructive' : 'secondary'"
                  size="sm"
                  class="h-8 gap-1.5 rounded-full px-2.5 text-xs"
                  [attr.aria-label]="isMeasurementMode() ? 'Turn off measurement mode' : 'Turn on measurement mode'"
                  (click)="isMeasurementMode.set(!isMeasurementMode())"
                >
                  <lucide-icon [img]="Ruler" class="size-3.5" />
                  <span class="hidden md:inline">Measure</span>
                </button>
              </div>
            </div>
          </div>
    
          <!-- Architectural Deep Dive Specs & Floor Schedule Cards -->
          <div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
            <!-- Active Room Overview Card -->
            <div ui-card class="shadow-xs lg:col-span-8">
              <div ui-card-header class="pb-3">
                <div class="flex flex-wrap items-center justify-between gap-2">
                  <div>
                    <h3 ui-card-title class="flex items-center gap-2 text-base font-bold sm:text-lg">
                      <lucide-icon [img]="currentRoomIcon" class="text-primary size-5" />
                      <span>{{ currentRoom().badgeName }}</span>
                    </h3>
                    <p ui-card-description class="text-xs">
                      {{ currentRoom().exposure }} · {{ currentRoom().floor }} · Verified Architectural Plan
                    </p>
                  </div>
                  <span ui-badge variant="outline" class="font-mono text-xs">
                    {{ currentRoom().area }}
                  </span>
                </div>
              </div>
              <div ui-card-content class="space-y-4">
                <p class="text-muted-foreground text-xs leading-relaxed sm:text-sm">{{ currentRoom().description }}</p>
    
                <!-- Room Architectural Metrics Grid -->
                <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
                  <div class="bg-muted/40 space-y-1 rounded-lg border p-2.5 text-xs">
                    <span class="text-muted-foreground block">Ceiling Height</span>
                    <span class="text-foreground font-mono font-bold">{{ currentRoom().ceilingHeight }}</span>
                  </div>
                  <div class="bg-muted/40 space-y-1 rounded-lg border p-2.5 text-xs">
                    <span class="text-muted-foreground block">Floor Area</span>
                    <span class="text-foreground font-mono font-bold">{{ areaSqFt() }} sq ft</span>
                  </div>
                  <div class="bg-muted/40 space-y-1 rounded-lg border p-2.5 text-xs">
                    <span class="text-muted-foreground block">Solar Aspect</span>
                    <span class="text-foreground font-semibold">{{ exposureFirst() }}</span>
                  </div>
                  <div class="bg-muted/40 space-y-1 rounded-lg border p-2.5 text-xs">
                    <span class="text-muted-foreground block">Tour Hotspots</span>
                    <span class="text-foreground font-mono font-bold">{{ currentRoom().hotspots.length }} Viewpoints</span>
                  </div>
                </div>
    
                <!-- Feature Bullet List -->
                <div class="space-y-2 border-t pt-3">
                  <h4 class="text-muted-foreground text-xs font-bold tracking-wider uppercase">
                    Finishes & Smart Building Integration
                  </h4>
                  <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
                    @for (f of currentRoom().features; track f.label) {
                      <div class="bg-muted/20 flex items-start gap-2 rounded-md border p-2 text-xs">
                        <lucide-icon [img]="ShieldCheck" class="text-success mt-0.5 size-3.5 shrink-0" />
                        <div>
                          <span class="text-muted-foreground font-medium">{{ f.label }}: </span>
                          <span class="text-foreground font-semibold">{{ f.value }}</span>
                        </div>
                      </div>
                    }
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Penthouse Residence Quick Facts & Viewing Inquire Card -->
            <div ui-card class="shadow-xs lg:col-span-4">
              <div ui-card-header class="pb-3">
                <h3 ui-card-title class="text-base font-bold">Residence 14B Specs</h3>
                <p ui-card-description class="text-xs">The Evergreen Penthouse Collection</p>
              </div>
              <div ui-card-content class="space-y-3.5 text-xs">
                <div class="space-y-2">
                  <div class="flex items-center justify-between border-b pb-1.5">
                    <span class="text-muted-foreground">Total Living Area</span>
                    <span class="font-mono font-bold">2,270 sq ft (210.8 m²)</span>
                  </div>
                  <div class="flex items-center justify-between border-b pb-1.5">
                    <span class="text-muted-foreground">Bedrooms / Baths</span>
                    <span class="font-semibold">3 Bed · 3.5 Bath</span>
                  </div>
                  <div class="flex items-center justify-between border-b pb-1.5">
                    <span class="text-muted-foreground">Private Outdoor Sky Deck</span>
                    <span class="font-mono font-bold">540 sq ft (Level 15)</span>
                  </div>
                  <div class="flex items-center justify-between border-b pb-1.5">
                    <span class="text-muted-foreground">Dedicated Valet EV Parking</span>
                    <span class="font-semibold">2 Stalls (Level P1)</span>
                  </div>
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground">Monthly HOA & Concierge</span>
                    <span class="font-mono font-bold">$1,840 / mo</span>
                  </div>
                </div>
    
                <div class="bg-primary/5 border-primary/20 space-y-1 rounded-lg border p-3">
                  <div class="text-primary flex items-center gap-1.5 font-semibold">
                    <lucide-icon [img]="Calendar" class="size-3.5" />
                    <span>Private VIP Walkthroughs</span>
                  </div>
                  <p class="text-muted-foreground text-xs leading-relaxed">
                    In-person appointments available Tuesdays & Thursdays by private broker registration.
                  </p>
                </div>
              </div>
              <div ui-card-footer class="pt-0">
                <button ui-button class="w-full gap-2 text-xs font-semibold shadow-xs">
                  <span>Schedule In-Person Penthouse Tour</span>
                  <lucide-icon [img]="ArrowRight" class="size-3.5" />
                </button>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiVirtualTourPanoramaComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Calendar = Calendar
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly ChevronDown = ChevronDown
      protected readonly ChevronLeft = ChevronLeft
      protected readonly ChevronRight = ChevronRight
      protected readonly ChevronUp = ChevronUp
      protected readonly Compass = Compass
      protected readonly Glasses = Glasses
      protected readonly Info = Info
      protected readonly MapIcon = MapIcon
      protected readonly Maximize2 = Maximize2
      protected readonly Minimize2 = Minimize2
      protected readonly Navigation = Navigation
      protected readonly Pause = Pause
      protected readonly Play = Play
      protected readonly RotateCcw = RotateCcw
      protected readonly Ruler = Ruler
      protected readonly Share2 = Share2
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Volume2 = Volume2
      protected readonly VolumeX = VolumeX
      protected readonly X = X
      protected readonly ZoomIn = ZoomIn
      protected readonly ZoomOut = ZoomOut
    
      @Input('class') className?: string
      @Input('rooms') set rooms(v: RoomData[] | undefined) {
        if (v !== undefined) this.roomsInput.set(v)
      }
    
      private readonly roomsInput = signal<RoomData[] | null>(null)
      readonly currentRoomId = signal<string>('master-bedroom')
      readonly yaw = signal<number>(0)
      readonly pitch = signal<number>(0)
      readonly zoom = signal<number>(1.0)
    
      private isDragging = false
      private dragStart = { x: 0, y: 0, yaw: 0, pitch: 0 }
    
      readonly isAutoRotating = signal<boolean>(false)
      readonly isMinimapOpen = signal<boolean>(true)
      readonly isMeasurementMode = signal<boolean>(false)
      readonly measurementUnit = signal<'imperial' | 'metric'>('imperial')
      readonly isVrMode = signal<boolean>(false)
      readonly isAudioActive = signal<boolean>(false)
      readonly isFullscreen = signal<boolean>(false)
      readonly copiedToast = signal<boolean>(false)
      readonly isRoomTransitioning = signal<boolean>(false)
      readonly activeInfoHotspot = signal<RoomHotspot | null>(null)
      readonly hoveredHotspotId = signal<string | null>(null)
      private toastTimer: ReturnType<typeof setTimeout> | undefined
      private roomTimers: ReturnType<typeof setTimeout>[] = []
      private autoRotateRaf: number | null = null
    
      readonly allRooms = computed(() => {
        const custom = this.roomsInput()
        return custom && custom.length > 0 ? custom : defaultRooms
      })
    
      readonly currentRoom = computed(() => {
        const rooms = this.allRooms()
        return rooms.find((r: RoomData) => r.id === this.currentRoomId()) || rooms[0]!
      })
    
      // Normalized Compass Heading
      readonly compassHeading = computed(() => {
        const normalized = ((this.yaw() % 360) + 360) % 360
        return Math.round(normalized)
      })
    
      readonly compassDirection = computed(() => {
        const h = this.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'
      })
    
      readonly areaSqFt = computed(() => this.currentRoom().area.split(' ')[0])
      readonly exposureFirst = computed(() => this.currentRoom().exposure.split(' ')[0])
    
      constructor() {
        const destroyRef = inject(DestroyRef)
        destroyRef.onDestroy(() => {
          clearTimeout(this.toastTimer)
          this.roomTimers.forEach((t) => clearTimeout(t))
          if (this.autoRotateRaf !== null && typeof cancelAnimationFrame !== 'undefined') {
            cancelAnimationFrame(this.autoRotateRaf)
          }
          if (typeof window !== 'undefined') window.removeEventListener('keydown', this.keyHandler)
        })
        if (typeof window !== 'undefined') window.addEventListener('keydown', this.keyHandler)
        effect(() => {
          if (this.isAutoRotating()) this.startAutoRotate()
          else this.stopAutoRotate()
        })
      }
    
      private readonly keyHandler = (e: KeyboardEvent) => {
        if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName)) return
        if (e.key === 'ArrowLeft') this.panStep(-10, 0)
        if (e.key === 'ArrowRight') this.panStep(10, 0)
        if (e.key === 'ArrowUp') this.panStep(0, 5)
        if (e.key === 'ArrowDown') this.panStep(0, -5)
        if (e.key === '+' || e.key === '=') this.zoomStep(0.1)
        if (e.key === '-' || e.key === '_') this.zoomStep(-0.1)
        if (e.key === 'm' || e.key === 'M') this.isMinimapOpen.update((v) => !v)
        if (e.key === 'r' || e.key === 'R') this.isMeasurementMode.update((v) => !v)
      }
    
      private startAutoRotate(): void {
        if (typeof requestAnimationFrame === 'undefined') return
        this.stopAutoRotate()
        const tick = () => {
          if (this.isAutoRotating() && !this.isDragging) {
            this.yaw.update((prev) => (prev + 0.18) % 360)
          }
          this.autoRotateRaf = requestAnimationFrame(tick)
        }
        this.autoRotateRaf = requestAnimationFrame(tick)
      }
    
      private stopAutoRotate(): void {
        if (this.autoRotateRaf !== null && typeof cancelAnimationFrame !== 'undefined') {
          cancelAnimationFrame(this.autoRotateRaf)
          this.autoRotateRaf = null
        }
      }
    
      get rootClass(): string {
        return cn(
          'bg-background text-foreground w-full space-y-4 font-sans transition-[background-color,padding] duration-300',
          this.isFullscreen() && 'fixed inset-0 z-50 overflow-y-auto bg-black p-4 sm:p-6',
          this.className,
        )
      }
    
      get currentRoomIcon() {
        return getRoomIcon(this.currentRoom().id)
      }
    
      dockRoomIcon(roomId: string) {
        return getRoomIcon(roomId)
      }
    
      get ambienceClass(): string {
        return cn('h-8 gap-1.5 px-2.5 text-xs', this.isAudioActive() && 'border-info/40 bg-info/10 text-info')
      }
    
      get vrClass(): string {
        return cn('h-8 gap-1.5 px-2.5 text-xs', this.isVrMode() && 'border-chart-1/40 bg-chart-1/10 text-chart-1')
      }
    
      get pitchLabel(): string {
        const p = Math.round(this.pitch())
        return p > 0 ? `+${p}°` : `${p}°`
      }
    
      parallaxX(): number {
        return -(this.yaw() / 360) * 320
      }
    
      parallaxY(): number {
        return -this.pitch() * 2
      }
    
      hotspotLeft(hotspot: RoomHotspot): number {
        return Math.max(8, Math.min(92, (hotspot.x - (this.yaw() / 360) * 100 + 150) % 100))
      }
    
      hotspotTop(hotspot: RoomHotspot): number {
        return Math.max(8, Math.min(88, hotspot.y + this.pitch() * 0.4))
      }
    
      hotspotTipClass(hotspot: RoomHotspot, border: string): string {
        return cn(
          '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',
          border,
          this.hoveredHotspotId() === hotspot.id ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0',
        )
      }
    
      minimapRoomClass(roomId: string): string {
        return cn(
          'transition-colors duration-200',
          this.currentRoomId() === roomId
            ? 'fill-success/25 stroke-success stroke-2'
            : 'fill-zinc-900/80 stroke-zinc-700 stroke-1 hover:fill-zinc-800',
        )
      }
    
      minimapLabelClass(roomId: string): string {
        return cn(
          'pointer-events-none text-xs font-semibold select-none',
          this.currentRoomId() === roomId ? 'fill-success font-bold' : 'fill-muted-foreground',
        )
      }
    
      fovTransform(): string {
        const coords = this.currentRoom().floorPlanCoords
        return `translate(${coords.cx}, ${coords.cy}) rotate(${this.yaw() + 180})`
      }
    
      dockRoomClass(roomId: string): string {
        return cn(
          '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',
          this.currentRoomId() === roomId
            ? 'bg-success text-white shadow-md'
            : 'border border-zinc-800 bg-zinc-900/90 text-zinc-300 hover:bg-zinc-800 hover:text-white',
        )
      }
    
      // Pan step helpers
      panStep(dx: number, dy: number): void {
        this.yaw.update((prev) => (prev + dx + 3600) % 360)
        this.pitch.update((prev) => Math.max(-22, Math.min(22, prev + dy)))
      }
    
      zoomStep(delta: number): void {
        this.zoom.update((prev) => Math.max(0.85, Math.min(1.5, prev + delta)))
      }
    
      resetView(): void {
        this.yaw.set(0)
        this.pitch.set(0)
        this.zoom.set(1.0)
      }
    
      toggleAutoRotate(): void {
        this.isAutoRotating.update((v) => !v)
      }
    
      // Room transition
      switchRoom(roomId: string): void {
        if (roomId === this.currentRoomId()) return
        this.isRoomTransitioning.set(true)
        this.activeInfoHotspot.set(null)
        this.roomTimers.push(
          setTimeout(() => {
            this.currentRoomId.set(roomId)
            this.yaw.set(0)
            this.pitch.set(0)
            this.roomTimers.push(
              setTimeout(() => {
                this.isRoomTransitioning.set(false)
              }, 280),
            )
          }, 220),
        )
      }
    
      // Mouse / Touch Interaction Handlers
      onPointerDown(e: MouseEvent | TouchEvent): void {
        this.isDragging = true
        const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX
        const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY
        this.dragStart = { x: clientX, y: clientY, yaw: this.yaw(), pitch: this.pitch() }
      }
    
      onPointerMove(e: MouseEvent | TouchEvent): void {
        if (!this.isDragging) return
        const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX
        const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY
        const deltaX = clientX - this.dragStart.x
        const deltaY = clientY - this.dragStart.y
        this.yaw.set((this.dragStart.yaw - deltaX * 0.25 + 3600) % 360)
        this.pitch.set(Math.max(-22, Math.min(22, this.dragStart.pitch + deltaY * 0.18)))
      }
    
      onPointerUp(): void {
        this.isDragging = false
      }
    
      navigateHotspot(hotspot: RoomHotspot, event: Event): void {
        event.stopPropagation()
        if (hotspot.targetRoomId) this.switchRoom(hotspot.targetRoomId)
      }
    
      inspectHotspot(hotspot: RoomHotspot, event: Event): void {
        event.stopPropagation()
        this.activeInfoHotspot.set(hotspot)
      }
    
      copyTourShareLink(): void {
        this.copiedToast.set(true)
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText('https://uipkge.dev/tour/742-evergreen-terrace')
        }
        clearTimeout(this.toastTimer)
        this.toastTimer = setTimeout(() => {
          this.copiedToast.set(false)
        }, 3200)
      }
    
      toggleFullscreen(): void {
        this.isFullscreen.update((v) => !v)
      }
    }
    
  • src/app/components/blocks/virtual-tour-panorama/virtual-tour-panorama-data.ts18.4 kB
  • src/app/components/blocks/virtual-tour-panorama/virtual-tour-panorama-types.ts0.9 kB
  • src/app/components/blocks/virtual-tour-panorama/index.ts0.2 kB

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