
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/floor-plan-explorer.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/floor-plan-explorer.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/floor-plan-explorer.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/floor-plan-explorer.jsonnpx shadcn-vue@latest add @uipkge/floor-plan-explorerInstalls to:app/components/blocks/floor-plan-explorer/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
Type aliases exported from this item's source. Use these to shape the data you pass in.
RoomSpecinterface 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
}
}AvailableUnitinterface 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
}FloorPlanDatainterface 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[]
}<script setup lang="ts">
import { computed, ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { Bath, Bed, Building2, Check, Download } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import AvailableUnitsTable from './AvailableUnitsTable.vue'
import FloorPlanCanvas from './FloorPlanCanvas.vue'
import { floorPlans } from './floor-plan-explorer-data'
import type { AvailableUnit, FloorPlanData, RoomSpec } from './floor-plan-explorer-types'
export type { AvailableUnit, FloorPlanData, RoomSpec } from './floor-plan-explorer-types'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const activeTab = ref('plan-b')
const selectedRoomId = ref<string | null>('living')
const hoveredRoomId = ref<string | null>(null)
const selectedUnitNumber = ref<string>('402')
const showDimensions = ref(true)
const showFurniture = ref(true)
const showPins = ref(true)
const unitSystem = ref<'imperial' | 'metric'>('imperial')
const isDownloadingPdf = ref(false)
const downloadSuccess = ref(false)
const showTourSuccess = ref(false)
const currentPlan = computed(() => floorPlans[activeTab.value] || floorPlans['plan-b'])
const activeRoom = computed(() => {
if (hoveredRoomId.value) {
return currentPlan.value.rooms.find((r) => r.id === hoveredRoomId.value) || null
}
if (selectedRoomId.value) {
return currentPlan.value.rooms.find((r) => r.id === selectedRoomId.value) || null
}
return currentPlan.value.rooms[0] || null
})
function handleSelectTab(key: string) {
activeTab.value = key
const plan = floorPlans[key]
if (plan) {
selectedRoomId.value = plan.rooms[0]?.id || null
hoveredRoomId.value = null
selectedUnitNumber.value = plan.availableUnits[0]?.unitNumber || ''
}
}
function handleSelectRoom(roomId: string) {
selectedRoomId.value = roomId === selectedRoomId.value ? null : roomId
}
function handleHoverRoom(roomId: string | null) {
hoveredRoomId.value = roomId
}
function handleSelectUnit(unitNo: string) {
selectedUnitNumber.value = unitNo
}
function handleDownloadPdf() {
isDownloadingPdf.value = true
downloadSuccess.value = false
setTimeout(() => {
isDownloadingPdf.value = false
downloadSuccess.value = true
setTimeout(() => {
downloadSuccess.value = false
}, 4000)
}, 900)
}
function handleScheduleTour() {
showTourSuccess.value = true
setTimeout(() => {
showTourSuccess.value = false
}, 4500)
}
</script>
<template>
<div data-slot="floor-plan-explorer" :class="cn('bg-background text-foreground w-full space-y-6', props.class)">
<!-- 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">
<Badge variant="outline" class="text-muted-foreground gap-1 px-2 py-0.5 font-mono text-xs">
<Building2 class="text-primary size-3" />
The Grandview Collection
</Badge>
<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
aria-label="Download attachment"
variant="outline"
size="sm"
class="h-9 gap-2 text-xs"
:disabled="isDownloadingPdf"
@click="handleDownloadPdf"
>
<Download v-if="!downloadSuccess" class="text-muted-foreground size-3.5" />
<Check v-else class="text-success size-3.5" />
<span>{{
isDownloadingPdf ? 'Generating PDF...' : downloadSuccess ? 'PDF Downloaded' : 'Download PDF Floor Plan'
}}</span>
</Button>
</div>
</header>
<!-- Plan Selection Tabs Bar -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Tabs :model-value="activeTab" class="w-full sm:w-auto" @update:model-value="handleSelectTab(String($event))">
<TabsList class="bg-muted/60 grid h-10 w-full grid-cols-3 gap-1 p-1 sm:w-auto">
<TabsTrigger
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
</TabsTrigger>
<TabsTrigger
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
</TabsTrigger>
<TabsTrigger
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
</TabsTrigger>
</TabsList>
</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="
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',
unitSystem === 'imperial'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="unitSystem = 'imperial'"
>
Sq Ft / ft
</button>
<button
type="button"
:class="
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',
unitSystem === 'metric'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="unitSystem = '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) -->
<FloorPlanCanvas
:plan="currentPlan"
:active-room="activeRoom"
:unit-system="unitSystem"
:show-dimensions="showDimensions"
:show-furniture="showFurniture"
:show-pins="showPins"
:selected-room-id="selectedRoomId"
:hovered-room-id="hoveredRoomId"
@toggle-dimensions="showDimensions = !showDimensions"
@toggle-furniture="showFurniture = !showFurniture"
@toggle-pins="showPins = !showPins"
@hover-room="handleHoverRoom"
@select-room="handleSelectRoom"
/>
<!-- 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 -->
<Card class="border-border bg-card shadow-xs">
<CardHeader class="pb-3">
<div class="flex flex-wrap items-center justify-between">
<Badge variant="secondary" class="font-mono text-xs">
{{ currentPlan.tier }}
</Badge>
<div class="text-muted-foreground flex items-center gap-1.5 text-xs">
<Bed class="size-3.5" />
<span>{{ currentPlan.beds }} Bed</span>
<span>·</span>
<Bath class="size-3.5" />
<span>{{ currentPlan.baths }} Bath</span>
</div>
</div>
<CardTitle class="mt-1 text-lg font-semibold tracking-tight sm:text-xl">
{{ currentPlan.marketingName }}
</CardTitle>
<CardDescription class="text-xs leading-relaxed sm:text-sm">
{{ currentPlan.description }}
</CardDescription>
</CardHeader>
<CardContent 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">
<div
v-for="room in currentPlan.rooms"
:key="room.id"
:class="
cn(
'flex cursor-pointer items-center justify-between rounded-md border p-2.5 transition-colors duration-150',
selectedRoomId === room.id
? 'border-primary bg-primary/5 shadow-xs'
: hoveredRoomId === room.id
? 'border-border bg-muted/60'
: 'border-border/60 bg-card hover:bg-muted/30',
)
"
@mouseenter="handleHoverRoom(room.id)"
@mouseleave="handleHoverRoom(null)"
@click="handleSelectRoom(room.id)"
>
<div class="flex min-w-0 items-center gap-2">
<span
:class="
cn(
'size-2 rounded-full transition-colors',
selectedRoomId === room.id
? 'bg-primary'
: hoveredRoomId === room.id
? 'bg-primary/70'
: 'bg-muted-foreground/40',
)
"
></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>
</CardContent>
</Card>
<!-- Available Units in Building Table -->
<AvailableUnitsTable
:plan="currentPlan"
:selected-unit-number="selectedUnitNumber"
:tour-success="showTourSuccess"
@select-unit="handleSelectUnit"
@schedule-tour="handleScheduleTour"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Home, Layers, MapPin, Ruler } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import type { FloorPlanData, RoomSpec } from './floor-plan-explorer-types'
defineProps<{
plan: FloorPlanData
activeRoom: RoomSpec | null
unitSystem: 'imperial' | 'metric'
showDimensions: boolean
showFurniture: boolean
showPins: boolean
selectedRoomId: string | null
hoveredRoomId: string | null
}>()
defineEmits<{
'toggle-dimensions': []
'toggle-furniture': []
'toggle-pins': []
'hover-room': [roomId: string | null]
'select-room': [roomId: string]
}>()
</script>
<template>
<!-- Left Column: 2D Architectural Layout Canvas (7 cols) -->
<div class="space-y-4 lg:col-span-7">
<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
variant="ghost"
size="sm"
:class="cn('h-7 gap-1 px-2 text-xs', showDimensions && 'bg-muted text-foreground')"
@click="$emit('toggle-dimensions')"
>
<Ruler class="text-muted-foreground size-3" />
<span>Dimensions</span>
</Button>
<Button
variant="ghost"
size="sm"
:class="cn('h-7 gap-1 px-2 text-xs', showFurniture && 'bg-muted text-foreground')"
@click="$emit('toggle-furniture')"
>
<Layers class="text-muted-foreground size-3" />
<span>Fixtures</span>
</Button>
<Button
variant="ghost"
size="sm"
:class="cn('h-7 gap-1 px-2 text-xs', showPins && 'bg-muted text-foreground')"
@click="$emit('toggle-pins')"
>
<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>
<template v-for="room in plan.rooms" :key="room.id">
<g
class="cursor-pointer transition-colors duration-200"
@mouseenter="$emit('hover-room', room.id)"
@mouseleave="$emit('hover-room', null)"
@click="$emit('select-room', room.id)"
>
<!-- Room Floor Fill -->
<rect
:x="room.rect.x"
:y="room.rect.y"
:width="room.rect.w"
:height="room.rect.h"
:fill="room.id === 'balcony' || room.id === 'sky-terrace' ? 'url(#deck-slats)' : 'currentColor'"
:class="
cn(
'transition-colors duration-150',
selectedRoomId === room.id || hoveredRoomId === room.id
? 'text-primary/15'
: room.id === 'balcony' || room.id === 'sky-terrace'
? 'text-muted/60'
: 'text-card',
)
"
/>
<!-- Room Border Perimeter -->
<rect
:x="room.rect.x"
:y="room.rect.y"
:width="room.rect.w"
:height="room.rect.h"
fill="none"
:class="
cn(
'transition-colors duration-150',
selectedRoomId === room.id
? 'stroke-primary stroke-[2.5]'
: hoveredRoomId === room.id
? 'stroke-primary/70 stroke-[2]'
: 'stroke-border/90 stroke-[1.5]',
)
"
/>
<!-- Architectural Fixtures Silhouette Layer -->
<g
v-if="showFurniture"
class="stroke-muted-foreground/35 text-muted-foreground/20 pointer-events-none fill-none"
>
<!-- Living Room: Sofa & Coffee Table -->
<template v-if="room.id === 'living' || room.id === 'grand-salon'">
<rect
:x="room.rect.x + 24"
:y="room.rect.y + 24"
width="75"
height="40"
rx="4"
stroke-width="1.2"
class="stroke-muted-foreground/40"
/>
<rect
:x="room.rect.x + 38"
:y="room.rect.y + 72"
width="46"
height="24"
rx="2"
stroke-width="1.2"
class="stroke-muted-foreground/30"
/>
<line
:x1="room.rect.x + 130"
:y1="room.rect.y + 20"
:x2="room.rect.x + 130"
:y2="room.rect.y + 110"
stroke-dasharray="3 3"
stroke-width="1"
class="stroke-muted-foreground/20"
/>
</template>
<!-- Bedroom: Bed Silhouette & Pillows -->
<template
v-if="
room.id === 'master-bed' ||
room.id === 'guest-bed' ||
room.id === 'primary-suite' ||
room.id === 'bed-2'
"
>
<rect
:x="room.rect.x + 20"
:y="room.rect.y + 25"
width="60"
height="75"
rx="3"
stroke-width="1.2"
class="stroke-muted-foreground/40"
/>
<rect
:x="room.rect.x + 26"
:y="room.rect.y + 30"
width="20"
height="14"
rx="2"
stroke-width="1"
class="stroke-muted-foreground/30"
/>
<rect
:x="room.rect.x + 54"
:y="room.rect.y + 30"
width="20"
height="14"
rx="2"
stroke-width="1"
class="stroke-muted-foreground/30"
/>
<line
:x1="room.rect.x + 20"
:y1="room.rect.y + 55"
:x2="room.rect.x + 80"
:y2="room.rect.y + 55"
stroke-width="1"
class="stroke-muted-foreground/20"
/>
</template>
<!-- Kitchen: Island & Cooktop / Prep Sink -->
<template v-if="room.id === 'kitchen'">
<rect
:x="room.rect.x + 18"
:y="room.rect.y + 20"
width="85"
height="36"
rx="2"
stroke-width="1.2"
class="stroke-muted-foreground/40"
/>
<circle
:cx="room.rect.x + 38"
:cy="room.rect.y + 38"
r="6"
stroke-width="1"
class="stroke-muted-foreground/40"
/>
<circle
:cx="room.rect.x + 56"
:cy="room.rect.y + 38"
r="6"
stroke-width="1"
class="stroke-muted-foreground/40"
/>
<rect
:x="room.rect.x + 72"
:y="room.rect.y + 30"
width="18"
height="16"
rx="1"
stroke-width="1"
class="stroke-muted-foreground/30"
/>
</template>
<!-- Bathrooms: Tub / Shower & Vanity -->
<template v-if="room.id === 'master-bath' || room.id === 'guest-bath' || room.id === 'spa-bath'">
<rect
:x="room.rect.x + 12"
:y="room.rect.y + 15"
width="38"
height="50"
rx="6"
stroke-width="1.2"
class="stroke-muted-foreground/40"
/>
<circle
:cx="room.rect.x + 31"
:cy="room.rect.y + 40"
r="3"
stroke-width="1"
class="stroke-muted-foreground/40"
/>
</template>
<!-- Balcony: Railing Post Accents -->
<template v-if="room.id === 'balcony' || room.id === 'sky-terrace'">
<line
:x1="room.rect.x"
:y1="room.rect.y"
:x2="room.rect.x"
:y2="room.rect.y + room.rect.h"
stroke-width="3"
class="stroke-primary/50"
/>
</template>
</g>
<!-- Dimension Leader Line Callouts -->
<g v-if="showDimensions" class="pointer-events-none select-none">
<!-- Room Dimension Text -->
<text
:x="room.pinX"
: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>
</template>
</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 -->
<g v-if="showPins">
<template v-for="room in plan.rooms" :key="`pin-${room.id}`">
<g
:transform="`translate(${room.pinX}, ${room.pinY - 8})`"
class="cursor-pointer transition-transform duration-150"
@mouseenter="$emit('hover-room', room.id)"
@mouseleave="$emit('hover-room', null)"
@click="$emit('select-room', room.id)"
>
<!-- Pulsing Halo on Selected -->
<circle
v-if="selectedRoomId === room.id || hoveredRoomId === room.id"
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="
cn(
'transition-colors duration-150',
selectedRoomId === room.id
? 'fill-primary stroke-primary-foreground stroke-1 shadow-md'
: hoveredRoomId === room.id
? 'fill-foreground stroke-background stroke-1'
: 'fill-card/90 stroke-border stroke-1 shadow-xs backdrop-blur-xs',
)
"
/>
<!-- Pin Label Text -->
<text
x="0"
y="4"
text-anchor="middle"
:class="
cn(
'font-mono text-xs font-semibold select-none',
selectedRoomId === room.id
? 'fill-primary-foreground'
: hoveredRoomId === room.id
? 'fill-background'
: 'fill-foreground',
)
"
>
{{ room.shortLabel }}
</text>
</g>
</template>
</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">
<Badge variant="outline" class="gap-1 font-mono text-xs font-normal">
<Home class="text-primary size-3" />
Active Focus: {{ activeRoom?.name || 'Overview' }}
</Badge>
<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>
</Card>
<!-- 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">
{{ plan.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' ? `${plan.exteriorSqFt} Sq Ft` : `${(plan.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">{{ plan.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">
${{ plan.startingRent.toLocaleString() }}<span class="text-muted-foreground text-xs font-normal">/mo</span>
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Calendar, CheckCircle2, FileText } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
import type { FloorPlanData } from './floor-plan-explorer-types'
defineProps<{
plan: FloorPlanData
selectedUnitNumber: string
tourSuccess: boolean
}>()
defineEmits<{
'select-unit': [unitNumber: string]
'schedule-tour': []
}>()
</script>
<template>
<!-- Available Units in Building Table -->
<Card class="border-border bg-card shadow-xs">
<CardHeader class="pb-3">
<div class="flex flex-wrap items-center justify-between">
<div>
<CardTitle class="text-sm font-semibold tracking-tight sm:text-base"> Available Units in Building </CardTitle>
<CardDescription class="text-xs">
Real-time occupancy and lease rates for {{ plan.tabLabel }}
</CardDescription>
</div>
<Badge variant="outline" class="font-mono text-xs">
{{ plan.availableUnits.filter((u) => u.status !== 'leased').length }} Available
</Badge>
</div>
</CardHeader>
<CardContent class="p-0">
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="hover:bg-transparent">
<TableHead class="w-[80px] text-xs font-medium">Unit</TableHead>
<TableHead class="text-xs font-medium">Floor & View</TableHead>
<TableHead class="text-right text-xs font-medium">Monthly Rent</TableHead>
<TableHead class="text-right text-xs font-medium">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="unit in plan.availableUnits"
:key="unit.unitNumber"
:class="
cn(
'cursor-pointer text-xs transition-colors',
selectedUnitNumber === unit.unitNumber ? 'bg-muted/80 font-medium' : 'hover:bg-muted/40',
)
"
@click="$emit('select-unit', unit.unitNumber)"
>
<TableCell class="text-foreground font-mono font-semibold tabular-nums">
Unit {{ unit.unitNumber }}
</TableCell>
<TableCell>
<div class="text-foreground text-xs">{{ unit.view }}</div>
<div class="text-muted-foreground text-xs">Floor {{ unit.floor }} · {{ unit.exposure }}</div>
</TableCell>
<TableCell class="text-foreground text-right font-mono font-semibold tabular-nums">
${{ unit.monthlyRent.toLocaleString() }}
<span class="text-muted-foreground font-normal">/mo</span>
</TableCell>
<TableCell class="text-right">
<Badge
v-if="unit.status === 'available-now'"
class="border-success/20 bg-success/10 text-success text-xs font-normal"
>
{{ unit.statusLabel }}
</Badge>
<Badge
v-else-if="unit.status === 'available-soon'"
class="border-info/20 bg-info/10 text-info text-xs font-normal"
>
{{ unit.statusLabel }}
</Badge>
<Badge
v-else-if="unit.status === 'under-contract'"
class="border-warning/20 bg-warning/10 text-warning text-xs font-normal"
>
{{ unit.statusLabel }}
</Badge>
<Badge v-else variant="secondary" class="text-muted-foreground text-xs font-normal">
{{ unit.statusLabel }}
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
<CardFooter 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 class="w-full gap-2 text-xs sm:flex-1" @click="$emit('schedule-tour')">
<Calendar class="size-3.5" />
<span>Schedule Private Showing</span>
</Button>
<Button variant="outline" class="w-full gap-2 text-xs sm:flex-1" @click="$emit('schedule-tour')">
<FileText class="size-3.5" />
<span>Apply for Unit</span>
</Button>
</div>
<div
v-if="tourSuccess"
class="bg-success/10 text-success flex w-full items-center gap-2 rounded-md p-2.5 text-xs"
>
<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>
</CardFooter>
</Card>
</template>
import type { FloorPlanData } from './floor-plan-explorer-types'
export 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',
},
],
},
}
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[]
}
Raw manifest:https://uipkge.dev/r/vue/floor-plan-explorer.json