Menu

Change language

GitHub repo

Floor Plan Explorer

blockreal-estate

Architectural unit floor plan inspector and residential layout explorer with 2D CAD-styled blueprint canvas, interactive room highlight pins, dimensional callouts, room breakdown specs, and building unit availability table.

Installation

$npx uipkge-ng@latest add floor-plan-explorer
Manifest:https://uipkge.dev/r/angular/floor-plan-explorer.jsonInstalls to:src/app/components/blocks/floor-plan-explorer/

Variants

Loading interactive previews…

Schema

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

RoomSpec
interface RoomSpec {
  id: string
  name: string
  shortLabel: string
  dimensionsImperial: string
  dimensionsMetric: string
  areaSqFt: number
  areaSqM: number
  flooring: string
  features: string[]
  pinX: number
  pinY: number
  rect: { x: number; y: number; w: number; h: number }
}
AvailableUnit
interface AvailableUnit {
  unitNumber: string
  floor: number
  view: string
  monthlyRent: number
  sqFt: number
  status: 'available-now' | 'available-soon' | 'leased' | 'under-contract'
  statusLabel: string
  moveInDate: string
  exposure: string
}
FloorPlanData
interface FloorPlanData {
  id: string
  tabKey: string
  tabLabel: string
  marketingName: string
  tier: string
  beds: number
  baths: number
  totalAreaSqFt: number
  totalAreaSqM: number
  interiorSqFt: number
  exteriorSqFt: number
  ceilingHeight: string
  exposure: string
  startingRent: number
  description: string
  highlights: string[]
  rooms: RoomSpec[]
  availableUnits: AvailableUnit[]
}

Files installed (2)

  • src/app/components/blocks/floor-plan-explorer/floor-plan-explorer.component.ts56.3 kB
    // Architectural unit floor plan inspector and residential layout explorer with a 2D
    // CAD-styled blueprint canvas, interactive room highlight pins, dimensional callouts,
    // room breakdown specs, and a building unit availability table. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      Bath,
      Bed,
      Building2,
      Calendar,
      Check,
      CheckCircle2,
      Download,
      FileText,
      Home,
      Layers,
      LucideAngularModule,
      MapPin,
      Ruler,
    } 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 {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import { UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent } from '@/ui/tabs/tabs.component'
    
    export interface RoomSpec {
      id: string
      name: string
      shortLabel: string
      dimensionsImperial: string
      dimensionsMetric: string
      areaSqFt: number
      areaSqM: number
      flooring: string
      features: string[]
      pinX: number
      pinY: number
      rect: { x: number; y: number; w: number; h: number }
    }
    
    export interface AvailableUnit {
      unitNumber: string
      floor: number
      view: string
      monthlyRent: number
      sqFt: number
      status: 'available-now' | 'available-soon' | 'leased' | 'under-contract'
      statusLabel: string
      moveInDate: string
      exposure: string
    }
    
    export interface FloorPlanData {
      id: string
      tabKey: string
      tabLabel: string
      marketingName: string
      tier: string
      beds: number
      baths: number
      totalAreaSqFt: number
      totalAreaSqM: number
      interiorSqFt: number
      exteriorSqFt: number
      ceilingHeight: string
      exposure: string
      startingRent: number
      description: string
      highlights: string[]
      rooms: RoomSpec[]
      availableUnits: AvailableUnit[]
    }
    
    const floorPlans: Record<string, FloorPlanData> = {
      'plan-a': {
        id: 'plan-a',
        tabKey: 'plan-a',
        tabLabel: 'Plan A - 1 Bed / 1 Bath',
        marketingName: 'Plan A - The Urban Haven',
        tier: 'Urban Gallery Residence',
        beds: 1,
        baths: 1,
        totalAreaSqFt: 780,
        totalAreaSqM: 72.5,
        interiorSqFt: 735,
        exteriorSqFt: 45,
        ceilingHeight: `10' 0" Finished`,
        exposure: 'East · Garden & Sunrise',
        startingRent: 3450,
        description:
          'Artfully designed open-concept layout featuring oversized east-facing windows, gourmet galley kitchen with quartz surfaces, secluded bedroom suite with walk-in closet, and a private sunrise terrace.',
        highlights: [
          'Floor-to-ceiling glass wall',
          'European quartz kitchen island',
          'Deep soaking tub & frameless shower',
          'Private sunrise terrace',
        ],
        rooms: [
          {
            id: 'living',
            name: 'Living & Dining Room',
            shortLabel: 'Living / Dining',
            dimensionsImperial: `16' × 12'`,
            dimensionsMetric: '4.9m × 3.7m',
            areaSqFt: 192,
            areaSqM: 17.8,
            flooring: 'Wide Plank Natural White Oak',
            features: ['East Sunrise Exposure', '9-Foot Glazing Wall', 'Concealed Media Wiring'],
            pinX: 280,
            pinY: 175,
            rect: { x: 170, y: 90, w: 230, h: 170 },
          },
          {
            id: 'master-bed',
            name: 'Master Bedroom',
            shortLabel: 'Master Bed',
            dimensionsImperial: `13' × 11'`,
            dimensionsMetric: '4.0m × 3.4m',
            areaSqFt: 143,
            areaSqM: 13.3,
            flooring: 'Plush Organic Wool Loop',
            features: ['Acoustic Insulation', 'Custom Closet Built-ins', 'Blackout Shade Pocket'],
            pinX: 505,
            pinY: 175,
            rect: { x: 410, y: 90, w: 190, h: 170 },
          },
          {
            id: 'kitchen',
            name: 'Gourmet Galley Kitchen',
            shortLabel: 'Kitchen',
            dimensionsImperial: `11' × 9'`,
            dimensionsMetric: '3.4m × 2.7m',
            areaSqFt: 99,
            areaSqM: 9.2,
            flooring: 'Calacatta Honed Quartzite',
            features: ['Integrated Bosch Appliances', 'Undermount LED Lights', 'Soft-Close Walnut Joinery'],
            pinX: 280,
            pinY: 330,
            rect: { x: 170, y: 270, w: 230, h: 120 },
          },
          {
            id: 'master-bath',
            name: 'Primary Full Bath',
            shortLabel: 'Full Bath',
            dimensionsImperial: `9' × 6'`,
            dimensionsMetric: '2.7m × 1.8m',
            areaSqFt: 54,
            areaSqM: 5.0,
            flooring: 'Matte Slate & Terrazzo',
            features: ['Kohler Undermount Sink', 'Frameless Glass Shower', 'Backlit Anti-Fog Vanity'],
            pinX: 505,
            pinY: 330,
            rect: { x: 410, y: 270, w: 190, h: 120 },
          },
          {
            id: 'balcony',
            name: 'Private Terrace',
            shortLabel: 'Terrace',
            dimensionsImperial: `9' × 5'`,
            dimensionsMetric: '2.7m × 1.5m',
            areaSqFt: 45,
            areaSqM: 4.2,
            flooring: 'Weathered Teak Decking',
            features: ['Architectural Steel Railing', 'Exterior Power Outlet', 'Garden Courtyard Vistas'],
            pinX: 105,
            pinY: 175,
            rect: { x: 50, y: 90, w: 110, h: 170 },
          },
        ],
        availableUnits: [
          {
            unitNumber: '304',
            floor: 3,
            view: 'Courtyard & Garden',
            monthlyRent: 3450,
            sqFt: 780,
            status: 'available-now',
            statusLabel: 'Available Now',
            moveInDate: 'Immediate',
            exposure: 'East',
          },
          {
            unitNumber: '604',
            floor: 6,
            view: 'Skyline Sunrise View',
            monthlyRent: 3650,
            sqFt: 780,
            status: 'available-soon',
            statusLabel: 'Available Oct 15',
            moveInDate: 'Oct 15, 2026',
            exposure: 'East / North',
          },
          {
            unitNumber: '904',
            floor: 9,
            view: 'High-Floor Park View',
            monthlyRent: 3800,
            sqFt: 780,
            status: 'leased',
            statusLabel: 'Leased',
            moveInDate: 'Occupied',
            exposure: 'East',
          },
        ],
      },
      'plan-b': {
        id: 'plan-b',
        tabKey: 'plan-b',
        tabLabel: 'Plan B - 2 Bed / 2 Bath',
        marketingName: 'Plan B - The Skyline Suite',
        tier: 'Signature Corner Residence',
        beds: 2,
        baths: 2,
        totalAreaSqFt: 1420,
        totalAreaSqM: 132.0,
        interiorSqFt: 1360,
        exteriorSqFt: 60,
        ceilingHeight: `10' 6" Finished`,
        exposure: 'Southwest · Bay & City Skyline',
        startingRent: 4850,
        description:
          'Expansive dual-exposure residence centered around a dramatic living and entertaining salon with floor-to-ceiling panoramic glass, chef-grade kitchen island, split master and guest wings, and a sheltered private terrace.',
        highlights: [
          'Dual-aspect corner positioning with 270° light',
          'Chef island with 10-foot marble waterfall',
          'Primary wing with dual walk-in closets',
          'Dedicated foyer vestibule & utility pantry',
        ],
        rooms: [
          {
            id: 'living',
            name: 'Living Room',
            shortLabel: 'Living Room',
            dimensionsImperial: `18' × 14'`,
            dimensionsMetric: '5.5m × 4.3m',
            areaSqFt: 252,
            areaSqM: 23.4,
            flooring: 'Wide Plank European White Oak',
            features: ['Floor-to-Ceiling Corner Glazing', 'Motorized Solar Shades', 'Recessed Architectural Lighting'],
            pinX: 255,
            pinY: 160,
            rect: { x: 150, y: 75, w: 220, h: 175 },
          },
          {
            id: 'master-bed',
            name: 'Master Bedroom',
            shortLabel: 'Master Bed',
            dimensionsImperial: `15' × 12'`,
            dimensionsMetric: '4.6m × 3.7m',
            areaSqFt: 180,
            areaSqM: 16.7,
            flooring: 'New Zealand Wool Weave',
            features: ['South Bay Views', 'Direct En-Suite Access', 'Custom Millwork Wardrobe'],
            pinX: 455,
            pinY: 145,
            rect: { x: 380, y: 75, w: 155, h: 150 },
          },
          {
            id: 'kitchen',
            name: 'Chef Kitchen & Dining',
            shortLabel: 'Kitchen',
            dimensionsImperial: `12' × 10'`,
            dimensionsMetric: '3.7m × 3.0m',
            areaSqFt: 120,
            areaSqM: 11.1,
            flooring: 'Calacatta Honed Quartzite',
            features: ['Waterfall Marble Island', 'Sub-Zero & Wolf Appliances', 'Pantry Cabinetry'],
            pinX: 255,
            pinY: 325,
            rect: { x: 150, y: 260, w: 220, h: 135 },
          },
          {
            id: 'master-bath',
            name: 'Master Bath',
            shortLabel: 'Master Bath',
            dimensionsImperial: `10' × 8'`,
            dimensionsMetric: '3.0m × 2.4m',
            areaSqFt: 80,
            areaSqM: 7.4,
            flooring: 'Bianco Carrara Marble',
            features: ['Dual Floating Vanities', 'Walk-in Rain Shower', 'Acoustic Privacy Door'],
            pinX: 590,
            pinY: 145,
            rect: { x: 545, y: 75, w: 90, h: 150 },
          },
          {
            id: 'guest-bed',
            name: 'Guest Bedroom / Study',
            shortLabel: 'Guest Bed',
            dimensionsImperial: `13' × 11'`,
            dimensionsMetric: '4.0m × 3.4m',
            areaSqFt: 143,
            areaSqM: 13.3,
            flooring: 'Wide Plank European White Oak',
            features: ['Multi-Function Home Office', 'South Window Exposure', 'Built-in Closet System'],
            pinX: 565,
            pinY: 320,
            rect: { x: 490, y: 245, w: 145, h: 150 },
          },
          {
            id: 'guest-bath',
            name: 'Guest Full Bath',
            shortLabel: 'Guest Bath',
            dimensionsImperial: `8' × 6'`,
            dimensionsMetric: '2.4m × 1.8m',
            areaSqFt: 48,
            areaSqM: 4.5,
            flooring: 'Smoked Gray Terrazzo',
            features: ['Full Soaking Tub', 'Modern Matte Black Fixtures', 'Linen Storage'],
            pinX: 430,
            pinY: 320,
            rect: { x: 380, y: 245, w: 100, h: 150 },
          },
          {
            id: 'balcony',
            name: 'Private Terrace',
            shortLabel: 'Terrace',
            dimensionsImperial: `10' × 6'`,
            dimensionsMetric: '3.0m × 1.8m',
            areaSqFt: 60,
            areaSqM: 5.6,
            flooring: 'Architectural Ipe Wood',
            features: ['Panoramic Bay Overlook', 'Weatherproof Recessed Sconces', 'Glass Wind Guard'],
            pinX: 90,
            pinY: 160,
            rect: { x: 40, y: 75, w: 100, h: 175 },
          },
        ],
        availableUnits: [
          {
            unitNumber: '402',
            floor: 4,
            view: 'South City View',
            monthlyRent: 4850,
            sqFt: 1420,
            status: 'available-now',
            statusLabel: 'Available Now',
            moveInDate: 'Immediate',
            exposure: 'South / West',
          },
          {
            unitNumber: '708',
            floor: 7,
            view: 'Bay & Marina View',
            monthlyRent: 5200,
            sqFt: 1420,
            status: 'available-soon',
            statusLabel: 'Available Sep 1',
            moveInDate: 'Sep 1, 2026',
            exposure: 'Bay View / South',
          },
          {
            unitNumber: '1102',
            floor: 11,
            view: 'Penthouse Level Panoramic',
            monthlyRent: 5600,
            sqFt: 1420,
            status: 'leased',
            statusLabel: 'Leased',
            moveInDate: 'Occupied',
            exposure: 'Panoramic South',
          },
        ],
      },
      'plan-c': {
        id: 'plan-c',
        tabKey: 'plan-c',
        tabLabel: 'Plan C - 3 Bed Penthouse',
        marketingName: 'Plan C - The Grandview Penthouse',
        tier: 'Crown Estate Penthouse',
        beds: 3,
        baths: 3.5,
        totalAreaSqFt: 2250,
        totalAreaSqM: 209.0,
        interiorSqFt: 2090,
        exteriorSqFt: 160,
        ceilingHeight: `11' 8" Finished`,
        exposure: '360° Panoramic · Harbor, Bay & Bridges',
        startingRent: 9400,
        description:
          'A true architectural masterpiece occupying the top levels of the tower. Direct key-lock private elevator access opens into a sweeping grand salon with 11.5-foot ceilings, full culinary kitchen with butler pantry, spa sanctuary primary suite, and wraparound sky terrace.',
        highlights: [
          'Private elevator vestibule with secure biometric access',
          'Wraparound sky terrace with built-in gas fire table',
          'Primary retreat with double walk-in dressing galleries',
          'Butler service pantry with climate-controlled wine vault',
        ],
        rooms: [
          {
            id: 'grand-salon',
            name: 'Grand Salon & Living',
            shortLabel: 'Grand Salon',
            dimensionsImperial: `24' × 16'`,
            dimensionsMetric: '7.3m × 4.9m',
            areaSqFt: 384,
            areaSqM: 35.7,
            flooring: 'Herringbone European Oak',
            features: ['360° Bay Vista Glazing', 'Gas Linear Fireplace', 'Custom Architectural Coving'],
            pinX: 275,
            pinY: 155,
            rect: { x: 160, y: 85, w: 240, h: 150 },
          },
          {
            id: 'primary-suite',
            name: 'Primary Penthouse Suite',
            shortLabel: 'Primary Suite',
            dimensionsImperial: `18' × 14'`,
            dimensionsMetric: '5.5m × 4.3m',
            areaSqFt: 252,
            areaSqM: 23.4,
            flooring: 'Custom Silk-Wool Blend Weave',
            features: ['Harbor Sunrise Exposure', 'Sitting Lounge Area', 'Dual Walk-in Closets'],
            pinX: 480,
            pinY: 155,
            rect: { x: 410, y: 85, w: 140, h: 150 },
          },
          {
            id: 'spa-bath',
            name: 'Spa Sanctuary Primary Bath',
            shortLabel: 'Spa Bath',
            dimensionsImperial: `12' × 9'`,
            dimensionsMetric: '3.7m × 2.7m',
            areaSqFt: 108,
            areaSqM: 10.0,
            flooring: 'Bookmatched Calacatta Marble',
            features: ['Freestanding Oval Soaking Tub', 'Dual Steam Shower', 'Radiant Heated Flooring'],
            pinX: 595,
            pinY: 155,
            rect: { x: 560, y: 85, w: 80, h: 150 },
          },
          {
            id: 'dining',
            name: 'Formal Dining Gallery',
            shortLabel: 'Dining Room',
            dimensionsImperial: `15' × 12'`,
            dimensionsMetric: '4.6m × 3.7m',
            areaSqFt: 180,
            areaSqM: 16.7,
            flooring: 'Herringbone European Oak',
            features: ['Seating for 10-12 Guests', 'Integrated Bar Cabinetry', 'Designer Chandelier Anchor'],
            pinX: 235,
            pinY: 315,
            rect: { x: 160, y: 245, w: 150, h: 145 },
          },
          {
            id: 'kitchen',
            name: 'Culinary Kitchen & Pantry',
            shortLabel: 'Kitchen',
            dimensionsImperial: `16' × 11'`,
            dimensionsMetric: '4.9m × 3.4m',
            areaSqFt: 176,
            areaSqM: 16.3,
            flooring: 'Taj Mahal Quartzite',
            features: ['12-Foot Monolithic Island', 'Gaggenau 400 Series Suite', 'Butler Prep Scullery'],
            pinX: 95,
            pinY: 315,
            rect: { x: 40, y: 245, w: 110, h: 145 },
          },
          {
            id: 'bed-2',
            name: 'Bedroom Suite 2',
            shortLabel: 'Bed 2 Suite',
            dimensionsImperial: `14' × 12'`,
            dimensionsMetric: '4.3m × 3.7m',
            areaSqFt: 168,
            areaSqM: 15.6,
            flooring: 'Wide Plank European Oak',
            features: ['En-Suite Private Bath', 'South Window Bank', 'Deep Built-in Wardrobe'],
            pinX: 395,
            pinY: 315,
            rect: { x: 320, y: 245, w: 150, h: 145 },
          },
          {
            id: 'bed-3',
            name: 'Bedroom 3 / Executive Study',
            shortLabel: 'Bed 3 / Study',
            dimensionsImperial: `13' × 11'`,
            dimensionsMetric: '4.0m × 3.4m',
            areaSqFt: 143,
            areaSqM: 13.3,
            flooring: 'Custom Walnut Parquetry',
            features: ['Integrated Library Shelving', 'Acoustic Pocket Doors', 'Corner View Light'],
            pinX: 560,
            pinY: 315,
            rect: { x: 480, y: 245, w: 160, h: 145 },
          },
          {
            id: 'sky-terrace',
            name: 'Wraparound Sky Terrace',
            shortLabel: 'Sky Terrace',
            dimensionsImperial: `20' × 8'`,
            dimensionsMetric: '6.1m × 2.4m',
            areaSqFt: 160,
            areaSqM: 14.9,
            flooring: 'Porcelain Pavers & Teak',
            features: ['Integrated Linear Gas Hearth', 'Outdoor Audio Pre-Wire', 'Frameless Wind Barrier'],
            pinX: 340,
            pinY: 45,
            rect: { x: 160, y: 25, w: 360, h: 50 },
          },
        ],
        availableUnits: [
          {
            unitNumber: 'PH-01',
            floor: 14,
            view: '360° Bay & City Skyline',
            monthlyRent: 9400,
            sqFt: 2250,
            status: 'available-now',
            statusLabel: 'Available Now',
            moveInDate: 'Immediate',
            exposure: '360° Panoramic',
          },
          {
            unitNumber: 'PH-02',
            floor: 14,
            view: 'Harbor Sunrise & Marina',
            monthlyRent: 9100,
            sqFt: 2250,
            status: 'under-contract',
            statusLabel: 'Under Contract',
            moveInDate: 'Pending',
            exposure: 'East / South',
          },
          {
            unitNumber: 'PH-03',
            floor: 15,
            view: 'Crown Sky Penthouse with Spa',
            monthlyRent: 10500,
            sqFt: 2250,
            status: 'available-soon',
            statusLabel: 'Available Nov 1',
            moveInDate: 'Nov 1, 2026',
            exposure: 'Crown Panoramic',
          },
        ],
      },
    }
    
    const planKeys = ['plan-a', 'plan-b', 'plan-c']
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-floor-plan-explorer, [ui-floor-plan-explorer]',
      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,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
        UiTabsComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
      ],
      template: `
        <div data-slot="floor-plan-explorer" [class]="rootClass">
          <!-- Header: Building Title, Plan Switcher Tabs, Action Button -->
          <header class="border-border flex flex-col gap-4 border-b pb-6 lg:flex-row lg:items-center lg:justify-between">
            <div class="space-y-1.5">
              <div class="flex items-center gap-2">
                <span ui-badge variant="outline" class="text-muted-foreground gap-1 px-2 py-0.5 font-mono text-xs">
                  <lucide-icon [img]="Building2" class="text-primary size-3" />
                  The Grandview Collection
                </span>
                <span class="text-muted-foreground text-xs">Tower West · Residences</span>
              </div>
              <h1 class="text-xl font-semibold tracking-tight sm:text-2xl">
                The Grandview Penthouse Collection · Floor Plan Explorer
              </h1>
              <p class="text-muted-foreground text-xs sm:text-sm">
                Interactive architectural layout inspector, dimensional specifications, and real-time residential unit
                availability.
              </p>
            </div>
    
            <div class="flex flex-wrap items-center gap-3">
              <button
                ui-button
                aria-label="Download attachment"
                variant="outline"
                size="sm"
                class="h-9 gap-2 text-xs"
                [disabled]="isDownloadingPdf()"
                (click)="downloadPdf()"
              >
                @if (!downloadSuccess()) {
                  <lucide-icon [img]="Download" class="text-muted-foreground size-3.5" />
                } @else {
                  <lucide-icon [img]="Check" class="text-success size-3.5" />
                }
                <span>{{ downloadLabel() }}</span>
              </button>
            </div>
          </header>
    
          <!-- Plan Selection Tabs Bar -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <ui-tabs [value]="activeTab()" (valueChange)="selectTab($event)" class="w-full sm:w-auto">
              <ui-tabs-list class="bg-muted/60 grid h-10 w-full grid-cols-3 gap-1 p-1 sm:w-auto">
                <button
                  ui-tabs-trigger
                  value="plan-a"
                  class="data-[state=active]:bg-background px-3 text-xs font-medium data-[state=active]:shadow-xs sm:px-4 sm:text-sm"
                >
                  Plan A - 1 Bed / 1 Bath
                </button>
                <button
                  ui-tabs-trigger
                  value="plan-b"
                  class="data-[state=active]:bg-background px-3 text-xs font-medium data-[state=active]:shadow-xs sm:px-4 sm:text-sm"
                >
                  Plan B - 2 Bed / 2 Bath
                </button>
                <button
                  ui-tabs-trigger
                  value="plan-c"
                  class="data-[state=active]:bg-background px-3 text-xs font-medium data-[state=active]:shadow-xs sm:px-4 sm:text-sm"
                >
                  Plan C - 3 Bed Penthouse
                </button>
              </ui-tabs-list>
            </ui-tabs>
    
            <div class="flex items-center gap-2">
              <span class="text-muted-foreground text-xs">Units:</span>
              <div class="border-border bg-muted/30 inline-flex rounded-md border p-0.5">
                <button type="button" [class]="unitToggleClass('imperial')" (click)="unitSystem.set('imperial')">
                  Sq Ft / ft
                </button>
                <button type="button" [class]="unitToggleClass('metric')" (click)="unitSystem.set('metric')">m² / m</button>
              </div>
            </div>
          </div>
    
          <!-- 2-Column Inspector: Left 2D CAD Blueprint Canvas, Right Specs & Availability -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- Left Column: 2D Architectural Layout Canvas (7 cols) -->
            <div class="space-y-4 lg:col-span-7">
              <div ui-card class="border-border bg-card overflow-hidden shadow-xs">
                <!-- Canvas Toolbar Header -->
                <div class="border-border bg-muted/30 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
                  <div class="flex items-center gap-2.5">
                    <span class="relative flex size-2">
                      <span class="bg-primary/60 absolute inline-flex size-full rounded-full opacity-75"></span>
                      <span class="bg-primary relative inline-flex size-2 rounded-full"></span>
                    </span>
                    <div>
                      <p class="text-foreground text-xs font-medium">2D Architectural CAD Schematic</p>
                      <p class="text-muted-foreground text-xs">Interactive room layout & dimensional spans</p>
                    </div>
                  </div>
    
                  <!-- Canvas View Options -->
                  <div class="flex items-center gap-1.5">
                    <button
                      ui-button
                      variant="ghost"
                      size="sm"
                      [class]="canvasToggleClass(showDimensions())"
                      (click)="showDimensions.set(!showDimensions())"
                    >
                      <lucide-icon [img]="Ruler" class="text-muted-foreground size-3" />
                      <span>Dimensions</span>
                    </button>
                    <button
                      ui-button
                      variant="ghost"
                      size="sm"
                      [class]="canvasToggleClass(showFurniture())"
                      (click)="showFurniture.set(!showFurniture())"
                    >
                      <lucide-icon [img]="Layers" class="text-muted-foreground size-3" />
                      <span>Fixtures</span>
                    </button>
                    <button
                      ui-button
                      variant="ghost"
                      size="sm"
                      [class]="canvasToggleClass(showPins())"
                      (click)="showPins.set(!showPins())"
                    >
                      <lucide-icon [img]="MapPin" class="text-muted-foreground size-3" />
                      <span>Pins</span>
                    </button>
                  </div>
                </div>
    
                <!-- Canvas Drawing Surface -->
                <div class="bg-card/95 relative p-3 sm:p-5">
                  <svg
                    viewBox="0 0 680 430"
                    class="border-border/70 bg-card w-full rounded border select-none"
                    xmlns="http://www.w3.org/2000/svg"
                  >
                    <defs>
                      <!-- Blueprint Architectural Grid Pattern -->
                      <pattern id="cad-grid-pattern" width="20" height="20" patternUnits="userSpaceOnUse">
                        <path d="M 20 0 L 0 0 0 20" fill="none" class="stroke-muted-foreground/10" stroke-width="0.75" />
                      </pattern>
                      <pattern id="cad-grid-major" width="100" height="100" patternUnits="userSpaceOnUse">
                        <rect width="100" height="100" fill="url(#cad-grid-pattern)" />
                        <path d="M 100 0 L 0 0 0 100" fill="none" class="stroke-muted-foreground/20" stroke-width="1.25" />
                      </pattern>
                      <!-- Balcony / Terrace Wood Decking Pattern -->
                      <pattern
                        id="deck-slats"
                        width="8"
                        height="8"
                        patternUnits="userSpaceOnUse"
                        patternTransform="rotate(45)"
                      >
                        <line x1="0" y1="0" x2="0" y2="8" class="stroke-muted-foreground/25" stroke-width="1.5" />
                      </pattern>
                    </defs>
    
                    <!-- Blueprint Grid Background -->
                    <rect width="680" height="430" fill="url(#cad-grid-major)" />
    
                    <!-- North Arrow Compass Widget -->
                    <g transform="translate(635, 38)" class="text-muted-foreground">
                      <circle cx="0" cy="0" r="18" fill="none" class="stroke-border" stroke-width="1.2" />
                      <path d="M 0 -13 L 4 4 L 0 1 L -4 4 Z" class="fill-primary" />
                      <path d="M 0 1 L 4 4 L 0 13 L -4 4 Z" class="fill-muted-foreground/30" />
                      <text x="0" y="-15" text-anchor="middle" class="fill-foreground font-mono text-xs font-bold">N</text>
                    </g>
    
                    <!-- Scale Indicator Bar -->
                    <g transform="translate(25, 412)" class="text-muted-foreground">
                      <line x1="0" y1="0" x2="100" y2="0" class="stroke-foreground" stroke-width="2" />
                      <line x1="0" y1="-4" x2="0" y2="4" class="stroke-foreground" stroke-width="2" />
                      <line x1="50" y1="-3" x2="50" y2="3" class="stroke-foreground" stroke-width="1.5" />
                      <line x1="100" y1="-4" x2="100" y2="4" class="stroke-foreground" stroke-width="2" />
                      <text x="0" y="-7" class="fill-muted-foreground font-mono text-xs">0</text>
                      <text x="46" y="-7" class="fill-muted-foreground font-mono text-xs">10'</text>
                      <text x="94" y="-7" class="fill-muted-foreground font-mono text-xs">20'</text>
                      <text x="115" y="3" class="fill-muted-foreground font-mono text-xs">Scale 1/4" = 1'0"</text>
                    </g>
    
                    <!-- Architectural Rooms Layout Polygons -->
                    <g>
                      @for (room of currentPlan().rooms; track room.id) {
                        <g
                          class="cursor-pointer transition-colors duration-200"
                          (mouseenter)="hoverRoom(room.id)"
                          (mouseleave)="hoverRoom(null)"
                          (click)="selectRoom(room.id)"
                        >
                          <!-- Room Floor Fill -->
                          <rect
                            [attr.x]="room.rect.x"
                            [attr.y]="room.rect.y"
                            [attr.width]="room.rect.w"
                            [attr.height]="room.rect.h"
                            [attr.fill]="isTerrace(room.id) ? 'url(#deck-slats)' : 'currentColor'"
                            [class]="roomFillClass(room.id)"
                          />
    
                          <!-- Room Border Perimeter -->
                          <rect
                            [attr.x]="room.rect.x"
                            [attr.y]="room.rect.y"
                            [attr.width]="room.rect.w"
                            [attr.height]="room.rect.h"
                            fill="none"
                            [class]="roomBorderClass(room.id)"
                          />
    
                          <!-- Architectural Fixtures Silhouette Layer -->
                          @if (showFurniture()) {
                            <g class="stroke-muted-foreground/35 text-muted-foreground/20 pointer-events-none fill-none">
                              <!-- Living Room: Sofa & Coffee Table -->
                              @if (room.id === 'living' || room.id === 'grand-salon') {
                                <rect
                                  [attr.x]="room.rect.x + 24"
                                  [attr.y]="room.rect.y + 24"
                                  width="75"
                                  height="40"
                                  rx="4"
                                  stroke-width="1.2"
                                  class="stroke-muted-foreground/40"
                                />
                                <rect
                                  [attr.x]="room.rect.x + 38"
                                  [attr.y]="room.rect.y + 72"
                                  width="46"
                                  height="24"
                                  rx="2"
                                  stroke-width="1.2"
                                  class="stroke-muted-foreground/30"
                                />
                                <line
                                  [attr.x1]="room.rect.x + 130"
                                  [attr.y1]="room.rect.y + 20"
                                  [attr.x2]="room.rect.x + 130"
                                  [attr.y2]="room.rect.y + 110"
                                  stroke-dasharray="3 3"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/20"
                                />
                              }
    
                              <!-- Bedroom: Bed Silhouette & Pillows -->
                              @if (
                                room.id === 'master-bed' ||
                                room.id === 'guest-bed' ||
                                room.id === 'primary-suite' ||
                                room.id === 'bed-2'
                              ) {
                                <rect
                                  [attr.x]="room.rect.x + 20"
                                  [attr.y]="room.rect.y + 25"
                                  width="60"
                                  height="75"
                                  rx="3"
                                  stroke-width="1.2"
                                  class="stroke-muted-foreground/40"
                                />
                                <rect
                                  [attr.x]="room.rect.x + 26"
                                  [attr.y]="room.rect.y + 30"
                                  width="20"
                                  height="14"
                                  rx="2"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/30"
                                />
                                <rect
                                  [attr.x]="room.rect.x + 54"
                                  [attr.y]="room.rect.y + 30"
                                  width="20"
                                  height="14"
                                  rx="2"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/30"
                                />
                                <line
                                  [attr.x1]="room.rect.x + 20"
                                  [attr.y1]="room.rect.y + 55"
                                  [attr.x2]="room.rect.x + 80"
                                  [attr.y2]="room.rect.y + 55"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/20"
                                />
                              }
    
                              <!-- Kitchen: Island & Cooktop / Prep Sink -->
                              @if (room.id === 'kitchen') {
                                <rect
                                  [attr.x]="room.rect.x + 18"
                                  [attr.y]="room.rect.y + 20"
                                  width="85"
                                  height="36"
                                  rx="2"
                                  stroke-width="1.2"
                                  class="stroke-muted-foreground/40"
                                />
                                <circle
                                  [attr.cx]="room.rect.x + 38"
                                  [attr.cy]="room.rect.y + 38"
                                  r="6"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/40"
                                />
                                <circle
                                  [attr.cx]="room.rect.x + 56"
                                  [attr.cy]="room.rect.y + 38"
                                  r="6"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/40"
                                />
                                <rect
                                  [attr.x]="room.rect.x + 72"
                                  [attr.y]="room.rect.y + 30"
                                  width="18"
                                  height="16"
                                  rx="1"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/30"
                                />
                              }
    
                              <!-- Bathrooms: Tub / Shower & Vanity -->
                              @if (room.id === 'master-bath' || room.id === 'guest-bath' || room.id === 'spa-bath') {
                                <rect
                                  [attr.x]="room.rect.x + 12"
                                  [attr.y]="room.rect.y + 15"
                                  width="38"
                                  height="50"
                                  rx="6"
                                  stroke-width="1.2"
                                  class="stroke-muted-foreground/40"
                                />
                                <circle
                                  [attr.cx]="room.rect.x + 31"
                                  [attr.cy]="room.rect.y + 40"
                                  r="3"
                                  stroke-width="1"
                                  class="stroke-muted-foreground/40"
                                />
                              }
    
                              <!-- Balcony: Railing Post Accents -->
                              @if (room.id === 'balcony' || room.id === 'sky-terrace') {
                                <line
                                  [attr.x1]="room.rect.x"
                                  [attr.y1]="room.rect.y"
                                  [attr.x2]="room.rect.x"
                                  [attr.y2]="room.rect.y + room.rect.h"
                                  stroke-width="3"
                                  class="stroke-primary/50"
                                />
                              }
                            </g>
                          }
    
                          <!-- Dimension Leader Line Callouts -->
                          @if (showDimensions()) {
                            <g class="pointer-events-none select-none">
                              <!-- Room Dimension Text -->
                              <text
                                [attr.x]="room.pinX"
                                [attr.y]="room.pinY + 16"
                                text-anchor="middle"
                                class="fill-muted-foreground font-mono text-xs font-medium"
                              >
                                {{ unitSystem() === 'imperial' ? room.dimensionsImperial : room.dimensionsMetric }}
                              </text>
                            </g>
                          }
                        </g>
                      }
                    </g>
    
                    <!-- Outer Perimeter Heavy Structural Walls -->
                    <g class="pointer-events-none">
                      <!-- Windows with Glass Double Thin Lines -->
                      <line
                        x1="160"
                        y1="75"
                        x2="380"
                        y2="75"
                        class="stroke-sky-400 dark:stroke-sky-300"
                        stroke-width="2.5"
                      />
                      <line
                        x1="390"
                        y1="75"
                        x2="540"
                        y2="75"
                        class="stroke-sky-400 dark:stroke-sky-300"
                        stroke-width="2.5"
                      />
                      <line
                        x1="550"
                        y1="75"
                        x2="635"
                        y2="75"
                        class="stroke-sky-400 dark:stroke-sky-300"
                        stroke-width="2.5"
                      />
                    </g>
    
                    <!-- Interactive Room Highlight Pins -->
                    @if (showPins()) {
                      <g>
                        @for (room of currentPlan().rooms; track 'pin-' + room.id) {
                          <g
                            [attr.transform]="'translate(' + room.pinX + ', ' + (room.pinY - 8) + ')'"
                            class="cursor-pointer transition-transform duration-150"
                            (mouseenter)="hoverRoom(room.id)"
                            (mouseleave)="hoverRoom(null)"
                            (click)="selectRoom(room.id)"
                          >
                            <!-- Pulsing Halo on Selected -->
                            @if (selectedRoomId() === room.id || hoveredRoomId() === room.id) {
                              <circle cx="0" cy="0" r="16" class="fill-primary/30" />
                            }
    
                            <!-- Pin Badge Background Pill -->
                            <rect x="-48" y="-12" width="96" height="24" rx="12" [class]="pinBadgeClass(room.id)" />
    
                            <!-- Pin Label Text -->
                            <text x="0" y="4" text-anchor="middle" [class]="pinLabelClass(room.id)">
                              {{ room.shortLabel }}
                            </text>
                          </g>
                        }
                      </g>
                    }
                  </svg>
                </div>
    
                <!-- Canvas Footer Status / Active Room Info -->
                <div class="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
                  <div class="flex items-center gap-2">
                    <span ui-badge variant="outline" class="gap-1 font-mono text-xs font-normal">
                      <lucide-icon [img]="Home" class="text-primary size-3" />
                      Active Focus: {{ activeRoom()?.name || 'Overview' }}
                    </span>
                    <span class="text-muted-foreground text-xs">
                      {{
                        unitSystem() === 'imperial' ? activeRoom()?.dimensionsImperial : activeRoom()?.dimensionsMetric
                      }}
                      ·
                      <span class="text-foreground font-mono font-medium tabular-nums">
                        {{
                          unitSystem() === 'imperial' ? activeRoom()?.areaSqFt + ' Sq Ft' : activeRoom()?.areaSqM + ' m²'
                        }}
                      </span>
                    </span>
                  </div>
    
                  <div class="text-muted-foreground flex items-center gap-2 text-xs">
                    <span class="hidden sm:inline">Finish:</span>
                    <span class="text-foreground font-medium">{{ activeRoom()?.flooring }}</span>
                  </div>
                </div>
              </div>
    
              <!-- Floor Plan Features / Highlights Strip -->
              <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-4">
                <div class="border-border bg-card rounded-lg border p-3 shadow-xs">
                  <p class="text-muted-foreground text-xs">Ceiling Height</p>
                  <p class="text-foreground mt-0.5 font-mono text-sm font-semibold tabular-nums">
                    {{ currentPlan().ceilingHeight }}
                  </p>
                </div>
                <div class="border-border bg-card rounded-lg border p-3 shadow-xs">
                  <p class="text-muted-foreground text-xs">Outdoor Space</p>
                  <p class="text-foreground mt-0.5 font-mono text-sm font-semibold tabular-nums">
                    {{
                      unitSystem() === 'imperial'
                        ? currentPlan().exteriorSqFt + ' Sq Ft'
                        : (currentPlan().exteriorSqFt * 0.0929).toFixed(1) + ' m²'
                    }}
                  </p>
                </div>
                <div class="border-border bg-card rounded-lg border p-3 shadow-xs">
                  <p class="text-muted-foreground text-xs">Exposure</p>
                  <p class="text-foreground mt-0.5 text-xs font-medium">{{ currentPlan().exposure }}</p>
                </div>
                <div class="border-border bg-card rounded-lg border p-3 shadow-xs">
                  <p class="text-muted-foreground text-xs">Starting Rent</p>
                  <p class="text-foreground mt-0.5 font-mono text-sm font-semibold tabular-nums">
                    \${{ currentPlan().startingRent.toLocaleString()
                    }}<span class="text-muted-foreground text-xs font-normal">/mo</span>
                  </p>
                </div>
              </div>
            </div>
    
            <!-- Right Column: Unit Specs, Room Breakdown & Available Units Table (5 cols) -->
            <div class="space-y-6 lg:col-span-5">
              <!-- Selected Plan Header Card with Total Area -->
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex flex-wrap items-center justify-between">
                    <span ui-badge variant="secondary" class="font-mono text-xs">
                      {{ currentPlan().tier }}
                    </span>
                    <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                      <lucide-icon [img]="Bed" class="size-3.5" />
                      <span>{{ currentPlan().beds }} Bed</span>
                      <span>·</span>
                      <lucide-icon [img]="Bath" class="size-3.5" />
                      <span>{{ currentPlan().baths }} Bath</span>
                    </div>
                  </div>
                  <h3 ui-card-title class="mt-1 text-lg font-semibold tracking-tight sm:text-xl">
                    {{ currentPlan().marketingName }}
                  </h3>
                  <p ui-card-description class="text-xs leading-relaxed sm:text-sm">
                    {{ currentPlan().description }}
                  </p>
                </div>
    
                <div ui-card-content class="space-y-4">
                  <!-- Total Area Hero Metric Box -->
                  <div class="border-border bg-muted/40 rounded-lg border p-4">
                    <p class="text-muted-foreground text-xs font-medium">Total Architectural Living Area</p>
                    <div class="mt-1 flex items-baseline gap-2">
                      <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">
                        {{ currentPlan().totalAreaSqFt.toLocaleString() }}
                      </span>
                      <span class="text-muted-foreground text-sm font-medium">Sq Ft</span>
                      <span class="text-muted-foreground/60">/</span>
                      <span class="text-muted-foreground font-mono text-lg font-semibold tabular-nums">
                        {{ currentPlan().totalAreaSqM.toFixed(1) }}
                      </span>
                      <span class="text-muted-foreground text-xs font-medium">m²</span>
                    </div>
                    <div
                      class="border-border/60 text-muted-foreground mt-2 flex flex-wrap items-center justify-between border-t pt-2 text-xs"
                    >
                      <span>
                        Interior:
                        <strong class="text-foreground font-mono tabular-nums"
                          >{{ currentPlan().interiorSqFt }} sq ft</strong
                        >
                      </span>
                      <span>
                        Terrace:
                        <strong class="text-foreground font-mono tabular-nums"
                          >{{ currentPlan().exteriorSqFt }} sq ft</strong
                        >
                      </span>
                    </div>
                  </div>
    
                  <!-- Room Breakdown List with Interactive Highlights -->
                  <div>
                    <div class="mb-2 flex flex-wrap items-center justify-between">
                      <h4 class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                        Room Breakdown & Dimensions
                      </h4>
                      <span class="text-muted-foreground text-xs">{{ currentPlan().rooms.length }} Spaces</span>
                    </div>
    
                    <div class="space-y-1.5">
                      @for (room of currentPlan().rooms; track room.id) {
                        <div
                          [class]="roomRowClass(room.id)"
                          (mouseenter)="hoverRoom(room.id)"
                          (mouseleave)="hoverRoom(null)"
                          (click)="selectRoom(room.id)"
                        >
                          <div class="flex min-w-0 items-center gap-2">
                            <span [class]="roomDotClass(room.id)"></span>
                            <div>
                              <p class="text-foreground text-xs font-medium">{{ room.name }}</p>
                              <p class="text-muted-foreground text-xs">{{ room.flooring }}</p>
                            </div>
                          </div>
    
                          <div class="shrink-0 text-right">
                            <p class="text-foreground font-mono text-xs font-semibold tabular-nums">
                              {{ unitSystem() === 'imperial' ? room.dimensionsImperial : room.dimensionsMetric }}
                            </p>
                            <p class="text-muted-foreground font-mono text-xs tabular-nums">
                              {{ unitSystem() === 'imperial' ? room.areaSqFt + ' sq ft' : room.areaSqM + ' m²' }}
                            </p>
                          </div>
                        </div>
                      }
                    </div>
                  </div>
                </div>
              </div>
    
              <!-- Available Units in Building Table -->
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex flex-wrap items-center justify-between">
                    <div>
                      <h3 ui-card-title class="text-sm font-semibold tracking-tight sm:text-base">
                        Available Units in Building
                      </h3>
                      <p ui-card-description class="text-xs">
                        Real-time occupancy and lease rates for {{ currentPlan().tabLabel }}
                      </p>
                    </div>
                    <span ui-badge variant="outline" class="font-mono text-xs"> {{ availableCount() }} Available </span>
                  </div>
                </div>
    
                <div ui-card-content class="p-0">
                  <div class="overflow-x-auto">
                    <table ui-table>
                      <thead ui-table-header>
                        <tr ui-table-row class="hover:bg-transparent">
                          <th ui-table-head class="w-[80px] text-xs font-medium">Unit</th>
                          <th ui-table-head class="text-xs font-medium">Floor & View</th>
                          <th ui-table-head class="text-right text-xs font-medium">Monthly Rent</th>
                          <th ui-table-head class="text-right text-xs font-medium">Status</th>
                        </tr>
                      </thead>
                      <tbody ui-table-body>
                        @for (unit of currentPlan().availableUnits; track unit.unitNumber) {
                          <tr ui-table-row [class]="unitRowClass(unit.unitNumber)" (click)="selectUnit(unit.unitNumber)">
                            <td ui-table-cell class="text-foreground font-mono font-semibold tabular-nums">
                              Unit {{ unit.unitNumber }}
                            </td>
                            <td ui-table-cell>
                              <div class="text-foreground text-xs">{{ unit.view }}</div>
                              <div class="text-muted-foreground text-xs">Floor {{ unit.floor }} · {{ unit.exposure }}</div>
                            </td>
                            <td ui-table-cell class="text-foreground text-right font-mono font-semibold tabular-nums">
                              \${{ unit.monthlyRent.toLocaleString() }}
                              <span class="text-muted-foreground font-normal">/mo</span>
                            </td>
                            <td ui-table-cell class="text-right">
                              <span
                                ui-badge
                                [variant]="unit.status === 'leased' ? 'secondary' : 'default'"
                                [class]="unitStatusClass(unit.status)"
                              >
                                {{ unit.statusLabel }}
                              </span>
                            </td>
                          </tr>
                        }
                      </tbody>
                    </table>
                  </div>
                </div>
    
                <div ui-card-footer class="border-border flex flex-col gap-2.5 border-t pt-4">
                  <div class="flex w-full flex-col gap-2 sm:flex-row">
                    <button ui-button class="w-full gap-2 text-xs sm:flex-1" (click)="scheduleTour()">
                      <lucide-icon [img]="Calendar" class="size-3.5" />
                      <span>Schedule Private Showing</span>
                    </button>
                    <button ui-button variant="outline" class="w-full gap-2 text-xs sm:flex-1" (click)="scheduleTour()">
                      <lucide-icon [img]="FileText" class="size-3.5" />
                      <span>Apply for Unit</span>
                    </button>
                  </div>
    
                  @if (showTourSuccess()) {
                    <div class="bg-success/10 text-success flex w-full items-center gap-2 rounded-md p-2.5 text-xs">
                      <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" />
                      <span>
                        Tour request received for Unit {{ selectedUnitNumber() }}. Leasing agent will contact you within 15
                        minutes.
                      </span>
                    </div>
                  }
    
                  <p class="text-muted-foreground text-center text-xs">
                    Self-guided tours and agent-accompanied showings available daily 9:00 AM – 7:00 PM.
                  </p>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiFloorPlanExplorerComponent {
      protected readonly Bath = Bath
      protected readonly Bed = Bed
      protected readonly Building2 = Building2
      protected readonly Calendar = Calendar
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Download = Download
      protected readonly FileText = FileText
      protected readonly Home = Home
      protected readonly Layers = Layers
      protected readonly MapPin = MapPin
      protected readonly Ruler = Ruler
    
      protected readonly planList = planKeys.map((key) => floorPlans[key]!)
    
      @Input() set initialPlan(plan: string) {
        if (plan && floorPlans[plan]) this.selectTab(plan)
      }
      @Input() set initialUnitSystem(system: 'imperial' | 'metric') {
        if (system === 'imperial' || system === 'metric') this.unitSystem.set(system)
      }
      @Input('class') className?: string
    
      readonly activeTab = signal('plan-b')
      readonly selectedRoomId = signal<string | null>('living')
      readonly hoveredRoomId = signal<string | null>(null)
      readonly selectedUnitNumber = signal('402')
      readonly showDimensions = signal(true)
      readonly showFurniture = signal(true)
      readonly showPins = signal(true)
      readonly unitSystem = signal<'imperial' | 'metric'>('imperial')
      readonly isDownloadingPdf = signal(false)
      readonly downloadSuccess = signal(false)
      readonly showTourSuccess = signal(false)
    
      private timers: ReturnType<typeof setTimeout>[] = []
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t)))
      }
    
      readonly currentPlan = computed(() => floorPlans[this.activeTab()] ?? floorPlans['plan-b']!)
    
      readonly activeRoom = computed<RoomSpec | null>(() => {
        const plan = this.currentPlan()
        const hovered = this.hoveredRoomId()
        if (hovered) return plan.rooms.find((r) => r.id === hovered) ?? null
        const selected = this.selectedRoomId()
        if (selected) return plan.rooms.find((r) => r.id === selected) ?? null
        return plan.rooms[0] ?? null
      })
    
      readonly availableCount = computed(
        () => this.currentPlan().availableUnits.filter((u) => u.status !== 'leased').length,
      )
    
      readonly downloadLabel = computed(() =>
        this.isDownloadingPdf()
          ? 'Generating PDF...'
          : this.downloadSuccess()
            ? 'PDF Downloaded'
            : 'Download PDF Floor Plan',
      )
    
      get rootClass(): string {
        return cn('bg-background text-foreground w-full space-y-6', this.className)
      }
    
      selectTab(key: string): void {
        this.activeTab.set(key)
        const plan = floorPlans[key]
        if (plan) {
          this.selectedRoomId.set(plan.rooms[0]?.id ?? null)
          this.hoveredRoomId.set(null)
          this.selectedUnitNumber.set(plan.availableUnits[0]?.unitNumber ?? '')
        }
      }
    
      selectRoom(roomId: string): void {
        this.selectedRoomId.update((prev) => (prev === roomId ? null : roomId))
      }
    
      hoverRoom(roomId: string | null): void {
        this.hoveredRoomId.set(roomId)
      }
    
      selectUnit(unitNumber: string): void {
        this.selectedUnitNumber.set(unitNumber)
      }
    
      isTerrace(roomId: string): boolean {
        return roomId === 'balcony' || roomId === 'sky-terrace'
      }
    
      unitToggleClass(system: 'imperial' | 'metric'): string {
        return cn(
          'focus-visible:ring-ring rounded px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.unitSystem() === system
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      canvasToggleClass(active: boolean): string {
        return cn('h-7 gap-1 px-2 text-xs', active && 'bg-muted text-foreground')
      }
    
      roomFillClass(roomId: string): string {
        return cn(
          'transition-colors duration-150',
          this.selectedRoomId() === roomId || this.hoveredRoomId() === roomId
            ? 'text-primary/15'
            : this.isTerrace(roomId)
              ? 'text-muted/60'
              : 'text-card',
        )
      }
    
      roomBorderClass(roomId: string): string {
        return cn(
          'transition-colors duration-150',
          this.selectedRoomId() === roomId
            ? 'stroke-primary stroke-[2.5]'
            : this.hoveredRoomId() === roomId
              ? 'stroke-primary/70 stroke-[2]'
              : 'stroke-border/90 stroke-[1.5]',
        )
      }
    
      roomRowClass(roomId: string): string {
        return cn(
          'flex cursor-pointer items-center justify-between rounded-md border p-2.5 transition-colors duration-150',
          this.selectedRoomId() === roomId
            ? 'border-primary bg-primary/5 shadow-xs'
            : this.hoveredRoomId() === roomId
              ? 'border-border bg-muted/60'
              : 'border-border/60 bg-card hover:bg-muted/30',
        )
      }
    
      roomDotClass(roomId: string): string {
        return cn(
          'size-2 rounded-full transition-colors',
          this.selectedRoomId() === roomId
            ? 'bg-primary'
            : this.hoveredRoomId() === roomId
              ? 'bg-primary/70'
              : 'bg-muted-foreground/40',
        )
      }
    
      pinBadgeClass(roomId: string): string {
        return cn(
          'transition-colors duration-150',
          this.selectedRoomId() === roomId
            ? 'fill-primary stroke-primary-foreground stroke-1 shadow-md'
            : this.hoveredRoomId() === roomId
              ? 'fill-foreground stroke-background stroke-1'
              : 'fill-card/90 stroke-border stroke-1 shadow-xs backdrop-blur-xs',
        )
      }
    
      pinLabelClass(roomId: string): string {
        return cn(
          'font-mono text-xs font-semibold select-none',
          this.selectedRoomId() === roomId
            ? 'fill-primary-foreground'
            : this.hoveredRoomId() === roomId
              ? 'fill-background'
              : 'fill-foreground',
        )
      }
    
      unitRowClass(unitNumber: string): string {
        return cn(
          'cursor-pointer text-xs transition-colors',
          this.selectedUnitNumber() === unitNumber ? 'bg-muted/80 font-medium' : 'hover:bg-muted/40',
        )
      }
    
      unitStatusClass(status: AvailableUnit['status']): string {
        switch (status) {
          case 'available-now':
            return 'border-success/20 bg-success/10 text-success text-xs font-normal'
          case 'available-soon':
            return 'border-info/20 bg-info/10 text-info text-xs font-normal'
          case 'under-contract':
            return 'border-warning/20 bg-warning/10 text-warning text-xs font-normal'
          default:
            return 'text-muted-foreground text-xs font-normal'
        }
      }
    
      downloadPdf(): void {
        if (this.isDownloadingPdf()) return
        this.isDownloadingPdf.set(true)
        this.downloadSuccess.set(false)
        this.timers.push(
          setTimeout(() => {
            this.isDownloadingPdf.set(false)
            this.downloadSuccess.set(true)
            this.timers.push(setTimeout(() => this.downloadSuccess.set(false), 4000))
          }, 900),
        )
      }
    
      scheduleTour(): void {
        this.showTourSuccess.set(true)
        this.timers.push(setTimeout(() => this.showTourSuccess.set(false), 4500))
      }
    }
    
  • src/app/components/blocks/floor-plan-explorer/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/floor-plan-explorer.json