// Residences — FLOOR-PLAN COMPARISON screen for The Universe (4 BHK). // ───────────────────────────────────────────────────────────────────────── // Compare up to THREE real 2D floor plans side by side: each column = a // pinch/drag/wheel/double-tap zoom-pan PLAN VIEWER + a spec row (RERA carpet, // BHK, block, price) + a sqft bar (normalized vs the // largest home, the 4689.98 sq.ft E&F penthouse). // · COMPARE mode — pick up to 3 typologies → up to 3 independent viewers // (each ~⅓ width at 3-up, type sizes auto-tighten). The // trailing track shows a "pick a plan" prompt until full; // at 3 the rail flashes a "max 3" hint instead of replacing. // · FOCUS mode — one big plan, single viewer. // No interior renders — this is purely about the real floor plans + specs. // The viewer gesture engine (PlanViewer) is reused verbatim from the prior // single-plan implementation, refactored into a self-contained component so // every column gets its own independent zoom/pan. // ── Largest home in the project — drives the sqft comparison bars. ────────── const MAX_SQFT = TYPOLOGIES.reduce((m, t) => Math.max(m, t.sqft), 0); // 4689.98 // ════════════════════════════════════════════════════════════════════════ // PlanViewer — self-contained zoom-pan floor-plan viewer (reused per column) // Pinch · drag · wheel (cursor-anchored) · double-tap reset · +/−/RESET HUD. // ════════════════════════════════════════════════════════════════════════ function PlanViewer({ typology, big }) { const [zoom, setZoom] = React.useState(1); const [pan, setPan] = React.useState({ x: 0, y: 0 }); const [hintFaded, setHintFaded] = React.useState(false); const containerRef = React.useRef(null); const gestureRef = React.useRef({ active: false, mode: null, startD: 0, startZoom: 1, startPanX: 0, startPanY: 0, startMidX: 0, startMidY: 0, lastTapAt: 0, lastTapX: 0, lastTapY: 0, dragStartX: 0, dragStartY: 0, }); // Reset zoom/pan whenever the plan changes. React.useEffect(() => { setZoom(1); setPan({ x: 0, y: 0 }); setHintFaded(false); }, [typology.code]); // Clamp pan so the plan can't drift entirely off-screen. const clampPan = (p, z) => { const el = containerRef.current; if (!el) return p; const r = el.getBoundingClientRect(); const maxX = Math.max(0, ((z - 1) / 2) * r.width) + 80; const maxY = Math.max(0, ((z - 1) / 2) * r.height) + 80; return { x: Math.max(-maxX, Math.min(maxX, p.x)), y: Math.max(-maxY, Math.min(maxY, p.y)), }; }; const fadeHint = () => { if (!hintFaded) setHintFaded(true); }; // ------- TOUCH ---------- const handleTouchStart = (ev) => { const g = gestureRef.current; const touches = ev.touches; const el = containerRef.current; const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0 }; if (touches.length === 2) { const [a, b] = [touches[0], touches[1]]; const dx = b.clientX - a.clientX, dy = b.clientY - a.clientY; g.mode = 'pinch'; g.active = true; g.startD = Math.hypot(dx, dy) || 1; g.startZoom = zoom; g.startPanX = pan.x; g.startPanY = pan.y; g.startMidX = (a.clientX + b.clientX) / 2 - rect.left; g.startMidY = (a.clientY + b.clientY) / 2 - rect.top; fadeHint(); } else if (touches.length === 1) { const now = performance.now(); const t0 = touches[0]; const tx = t0.clientX, ty = t0.clientY; // Double-tap → reset if (now - g.lastTapAt < 320 && Math.hypot(tx - g.lastTapX, ty - g.lastTapY) < 40) { setZoom(1); setPan({ x: 0, y: 0 }); g.lastTapAt = 0; g.active = false; g.mode = null; fadeHint(); return; } g.lastTapAt = now; g.lastTapX = tx; g.lastTapY = ty; // Single-finger pan only when zoomed in if (zoom > 1) { g.mode = 'pan-touch'; g.active = true; g.dragStartX = tx; g.dragStartY = ty; g.startPanX = pan.x; g.startPanY = pan.y; fadeHint(); } else { g.mode = null; g.active = false; } } }; const handleTouchMove = (ev) => { const g = gestureRef.current; if (!g.active) return; if (ev.cancelable) ev.preventDefault(); const touches = ev.touches; if (g.mode === 'pinch' && touches.length === 2) { const [a, b] = [touches[0], touches[1]]; const dx = b.clientX - a.clientX, dy = b.clientY - a.clientY; const newD = Math.hypot(dx, dy) || 1; const ratio = newD / g.startD; const newZoom = Math.max(0.5, Math.min(4, g.startZoom * ratio)); const el = containerRef.current; const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0, width: 1, height: 1 }; const cx = (a.clientX + b.clientX) / 2 - rect.left; const cy = (a.clientY + b.clientY) / 2 - rect.top; const k = newZoom / g.startZoom; const offX = (cx - g.startMidX) + g.startPanX * k; const offY = (cy - g.startMidY) + g.startPanY * k; setZoom(newZoom); setPan(clampPan({ x: offX, y: offY }, newZoom)); } else if (g.mode === 'pan-touch' && touches.length === 1) { const t0 = touches[0]; const dx = t0.clientX - g.dragStartX; const dy = t0.clientY - g.dragStartY; setPan(clampPan({ x: g.startPanX + dx, y: g.startPanY + dy }, zoom)); } }; const handleTouchEnd = (ev) => { const g = gestureRef.current; if (ev.touches.length === 0) { g.active = false; g.mode = null; } else if (ev.touches.length === 1 && g.mode === 'pinch') { const t0 = ev.touches[0]; g.mode = zoom > 1 ? 'pan-touch' : null; g.active = g.mode === 'pan-touch'; g.dragStartX = t0.clientX; g.dragStartY = t0.clientY; g.startPanX = pan.x; g.startPanY = pan.y; } }; // ------- MOUSE ---------- const handleMouseDown = (ev) => { if (zoom <= 1) return; const g = gestureRef.current; g.mode = 'pan-mouse'; g.active = true; g.dragStartX = ev.clientX; g.dragStartY = ev.clientY; g.startPanX = pan.x; g.startPanY = pan.y; fadeHint(); }; const handleMouseMove = (ev) => { const g = gestureRef.current; if (!g.active || g.mode !== 'pan-mouse') return; const dx = ev.clientX - g.dragStartX; const dy = ev.clientY - g.dragStartY; setPan(clampPan({ x: g.startPanX + dx, y: g.startPanY + dy }, zoom)); }; const handleMouseUp = () => { const g = gestureRef.current; if (g.mode === 'pan-mouse') { g.active = false; g.mode = null; } }; const handleMouseLeave = () => { const g = gestureRef.current; if (g.mode === 'pan-mouse') { g.active = false; g.mode = null; } }; // ------- WHEEL (cursor-anchored) ---------- const handleWheel = (ev) => { if (ev.cancelable) ev.preventDefault(); fadeHint(); const el = containerRef.current; const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0, width: 1, height: 1 }; const cx = ev.clientX - rect.left - rect.width / 2; const cy = ev.clientY - rect.top - rect.height / 2; const delta = ev.deltaY > 0 ? -0.1 : 0.1; const newZoom = Math.max(0.5, Math.min(4, zoom * (1 + delta))); const k = newZoom / zoom; const newPan = { x: cx - (cx - pan.x) * k, y: cy - (cy - pan.y) * k }; setZoom(newZoom); setPan(clampPan(newPan, newZoom)); }; // Register passive:false touchmove + wheel listeners (React inline can't). React.useEffect(() => { const el = containerRef.current; if (!el) return; const tm = (ev) => handleTouchMove(ev); const wh = (ev) => handleWheel(ev); el.addEventListener('touchmove', tm, { passive: false }); el.addEventListener('wheel', wh, { passive: false }); return () => { el.removeEventListener('touchmove', tm); el.removeEventListener('wheel', wh); }; }, [zoom, pan.x, pan.y, hintFaded, typology.code]); return (
1 ? (gestureRef.current.mode === 'pan-mouse' ? 'grabbing' : 'grab') : 'default', touchAction: 'none', }}> {typology.name} { e.currentTarget.style.opacity = 0.0; }} />
{/* block / N-arrow chip */}
N
BLOCK {typology.pair}
{/* zoom controls */}
{Math.round(zoom * 100)}%
{/* gesture hint — fades after first interaction */}
PINCH · DRAG · DOUBLE-TAP
); } const zoomBtn = { width: 50, height: 50, borderRadius: '50%', background: 'rgba(20,16,11,0.88)', border: '1px solid rgba(232,215,168,0.30)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'rgba(232,215,168,0.95)', }; // ════════════════════════════════════════════════════════════════════════ // Spec block — RERA carpet + sqft bar + BHK / block / price, per column. // ════════════════════════════════════════════════════════════════════════ function SpecBlock({ typology, t, delay, compact, dens = 0 }) { const e = ease.outQuart(clamp((t - delay) / 0.6, 0, 1)); const barFrac = typology.sqft / MAX_SQFT; const barFill = ease.outQuart(clamp((t - delay - 0.15) / 0.7, 0, 1)); const isMax = typology.sqft === MAX_SQFT; const isPent = typology.name.toLowerCase().includes('penthouse'); // At 3-up the columns are ~⅓ width, so dial down the display type sizes. // `dens` (0 → 1, tablet → iPad Pro) bumps every value so specs stay legible on // the taller canvas without regressing 16:10. const d = compact ? dens * 0.5 : dens; // 3-up columns are narrow, grow gently const nameSize = Math.round((compact ? 32 : 46) + d*8); const carpetSize = Math.round((compact ? 30 : 40) + d*8); const carpetUnit = Math.round((compact ? 16 : 20) + d*3); const rowVal = Math.round((compact ? 18 : 21) + d*4); const rowPadY = Math.round((compact ? 11 : 13) + d*5); const secGap = Math.round((compact ? 14 : 18) + d*8); return (
{/* name + tag */}
{typology.tag.toUpperCase()}
{typology.name}
{/* carpet headline */}
RERA CARPET (APPROX)
{typology.sqft.toLocaleString('en-IN', { maximumFractionDigits: 0 })} sq.ft
{/* sqft comparison bar (normalized to the largest home) */}
{Math.round(barFrac * 100)}% OF LARGEST HOME {isMax && LARGEST}
{/* spec rows */}
{[ ['CONFIGURATION', `${typology.bhk} BHK`], ['BLOCK', `Block ${typology.pair}`], ['PRICE', formatINR(typology.price)], ].map(([k, v], i) => (
{k}
{v}
))}
); } // ════════════════════════════════════════════════════════════════════════ // A single comparison column = viewer + spec + a remove/swap affordance. // ════════════════════════════════════════════════════════════════════════ function CompareColumn({ typology, t, delay, onClear, clearable, compact, dens = 0 }) { return (
{clearable && ( )}
); } // "+ Add a plan" track — a slim button column to add another plan (up to 3). function AddColumn({ onAdd }) { return ( ); } // ════════════════════════════════════════════════════════════════════════ // Residences (root) — selection rail + COMPARE / FOCUS modes. // ════════════════════════════════════════════════════════════════════════ function Residences() { const t = useLoop(); const [route] = useRoute(); // Canvas-height density: 0 on 16:10 tablet (1600) → 1 on iPad Pro 4:3 (1920). const CH = (typeof window !== 'undefined' && window.UNIVERSE_CANVAS && window.UNIVERSE_CANVAS.H) || 1600; const dens = clamp((CH - 1600) / 320); // Initial typology from route (tower id → its block pair), else first. const initialCode = (() => { const towerId = route.params && route.params[0]; if (towerId) { const tower = TOWERS.find(tw => tw.id === towerId); if (tower) { const ty = TYPOLOGIES.find(t => t.pair === tower.pair); if (ty) return ty.code; } } return TYPOLOGIES[0].code; })(); const MAX_COMPARE = 3; // default to TWO plans side by side (initial + the next distinct typology) const secondCode = (TYPOLOGIES.find(ty => ty.code !== initialCode) || TYPOLOGIES[0]).code; const [mode, setMode] = React.useState('compare'); // 'compare' | 'focus' const [selected, setSelected] = React.useState( secondCode && secondCode !== initialCode ? [initialCode, secondCode] : [initialCode] ); const [focusCode, setFocusCode] = React.useState(initialCode); const [maxHint, setMaxHint] = React.useState(false); // brief "max 3" flash const byCode = (c) => TYPOLOGIES.find(ty => ty.code === c); // Rail tap behaviour depends on mode. const pick = (code) => { if (mode === 'focus') { setFocusCode(code); return; } setSelected(prev => { if (prev.includes(code)) { // tapping a selected one removes it (unless it's the only one) if (prev.length === 1) return prev; return prev.filter(c => c !== code); } if (prev.length >= MAX_COMPARE) { // already at the cap — flash a "max 3" hint, keep selection unchanged setMaxHint(true); return prev; } return [...prev, code]; }); }; // "+ Add plan" — append the next typology not yet being compared (up to 3). const addPlan = () => setSelected(prev => { if (prev.length >= MAX_COMPARE) return prev; const next = TYPOLOGIES.find(ty => !prev.includes(ty.code)); return next ? [...prev, next.code] : prev; }); // Auto-dismiss the "max 3" hint shortly after it appears. React.useEffect(() => { if (!maxHint) return; const id = setTimeout(() => setMaxHint(false), 2200); return () => clearTimeout(id); }, [maxHint]); const isActive = (code) => mode === 'focus' ? focusCode === code : selected.includes(code); const slotOf = (code) => selected.indexOf(code); // 0 / 1 / 2 / -1 const cols = selected.map(byCode).filter(Boolean); // 1–3 chosen typologies const compact = cols.length === 3; // tighten type at 3-up const showEmpty = cols.length < MAX_COMPARE && cols.length >= 1; // trailing prompt slot // 1 chosen → show its column + an empty prompt (2 tracks). // 2 chosen → two columns + an empty prompt (3 tracks). // 3 chosen → three columns, no prompt. const trackCount = cols.length >= MAX_COMPARE ? MAX_COMPARE : cols.length + 1; return (
{/* ── mode toggle (Compare / Focus) ── top-right under header ── */}
{[['compare', 'COMPARE'], ['focus', 'SINGLE PLAN']].map(([m, label]) => ( ))}
{/* ── selection rail (the 6 typologies) ── */}
{TYPOLOGIES.map(ty => { const active = isActive(ty.code); const slot = slotOf(ty.code); return ( ); })}
{/* ── selection hint ── */}
{mode === 'compare' ? `SELECT UP TO THREE PLANS TO COMPARE SIDE BY SIDE · ${selected.length}/${MAX_COMPARE} CHOSEN` : 'SELECT ONE PLAN TO STUDY FULL-SIZE'} {mode === 'compare' && ( · MAX {MAX_COMPARE} · REMOVE ONE TO SWAP )}
{/* ── stage ── */}
{mode === 'compare' ? (
= MAX_COMPARE ? `repeat(${MAX_COMPARE}, 1fr)` : `repeat(${cols.length}, 1fr) minmax(230px, 0.52fr)`, gap: compact ? 34 : 46, height: '100%', minWidth: 0, }}> {cols.map((ty, i) => ( 1} onClear={() => setSelected(prev => prev.filter(c => c !== ty.code))} /> ))} {cols.length < MAX_COMPARE && }
) : (
{/* big single viewer */} {/* spec + room schedule + CTA */}
)}
); } // Right-hand panel for FOCUS mode — fuller spec + room schedule + CTA. function FocusSpec({ typology, t, dens = 0 }) { const rooms = parseRooms(typology.code); const isPent = typology.name.toLowerCase().includes('penthouse'); return (
{/* room schedule (scrolls) */}
ROOM SCHEDULE
{rooms.map((r, i) => { const e = ease.outQuart(clamp((t - 0.3 - i * 0.035) / 0.5, 0, 1)); return (
{r.name}
{r.dim}
); })}
); } // ── Room schedules per typology code — from the official Unit Plans PDF. ──── function parseRooms(code) { const schedules = { 'AB-4BHK': [ { name: 'Living / Dining', dim: '20\'-5" × 15\'-1"' }, { name: 'Master Bedroom', dim: '16\'-7" × 11\'-0"' }, { name: 'Bedroom 1', dim: '11\'-11" × 14\'-8"' }, { name: 'Bedroom 2', dim: '11\'-0" × 13\'-9"' }, { name: 'Bedroom 3', dim: '14\'-0" × 11\'-11"' }, { name: 'Kitchen', dim: '11\'-0" × 9\'-0"' }, { name: 'Utility', dim: '5\'-7" × 6\'-5"' }, { name: 'Puja', dim: '3\'-7" × 3\'-1"' }, { name: 'Foyer', dim: '3\'-11" × 9\'-0"' }, { name: 'Balcony', dim: '12\'-1" × 5\'-0"' }, { name: 'Toilets', dim: '4 attached' }, ], 'CD-4BHK': [ { name: 'Living / Dining', dim: '30\'-4" × 16\'-4"' }, { name: 'Family Lounge', dim: '12\'-7" × 6\'-8"' }, { name: 'Bedroom 1', dim: '11\'-0" × 16\'-0"' }, { name: 'Bedroom 2', dim: '11\'-0" × 15\'-8"' }, { name: 'Bedroom 3', dim: '10\'-6" × 15\'-0"' }, { name: 'Bedroom 4', dim: '14\'-0" × 11\'-6"' }, { name: 'Kitchen', dim: '9\'-0" × 14\'-11"' }, { name: 'Utility', dim: '8\'-4" × 7\'-6"' }, { name: 'Servant', dim: '5\'-0" × 11\'-8"' }, { name: 'Balcony', dim: '29\'-9" × 6\'-0"' }, ], 'EF-4BHK': [ { name: 'Living', dim: '25\'-8" × 16\'-10"' }, { name: 'Dining', dim: '15\'-4" × 8\'-3"' }, { name: 'Master Bedroom', dim: '20\'-6" × 11\'-0"' }, { name: 'Bedroom 1', dim: '13\'-1" × 13\'-2"' }, { name: 'Bedroom 2', dim: '17\'-4" × 11\'-0"' }, { name: 'Bedroom 3', dim: '17\'-4" × 11\'-0"' }, { name: 'Kitchen', dim: '10\'-0" × 18\'-10"' }, { name: 'Servant', dim: '11\'-8" × 5\'-5"' }, { name: 'Utility', dim: '11\'-9" × 6\'-5"' }, { name: 'Store', dim: '6\'-4" × 7\'-0"' }, { name: 'Balcony', dim: '25\'-6" × 7\'-1"' }, ], 'GH-4BHK': [ { name: 'Living / Dining / Family', dim: '27\'-4" × 15\'-2"' }, { name: 'Master Bedroom', dim: '16\'-7" × 11\'-8"' }, { name: 'Bedroom A', dim: '16\'-0" × 11\'-11"' }, { name: 'Bedroom B', dim: '11\'-8" × 15\'-9"' }, { name: 'Bedroom C', dim: '11\'-0" × 15\'-0"' }, { name: 'Kitchen', dim: '10\'-0" × 14\'-4"' }, { name: 'Servant', dim: '8\'-6" × 5\'-6"' }, { name: 'Utility', dim: '7\'-5" × 7\'-2"' }, { name: 'Balcony', dim: '22\'-8" × 6\'-0"' }, ], 'IJ-4BHK': [ { name: 'Living / Dining', dim: '30\'-5" × 16\'-4"' }, { name: 'Family Lounge', dim: '11\'-0" × 6\'-8"' }, { name: 'Bedroom 1', dim: '11\'-0" × 16\'-0"' }, { name: 'Bedroom 2', dim: '11\'-0" × 15\'-8"' }, { name: 'Bedroom 3', dim: '10\'-6" × 15\'-0"' }, { name: 'Bedroom 4', dim: '14\'-0" × 11\'-6"' }, { name: 'Kitchen', dim: '9\'-0" × 14\'-11"' }, { name: 'Utility', dim: '8\'-4" × 7\'-6"' }, { name: 'Servant', dim: '5\'-0" × 11\'-8"' }, { name: 'Balcony', dim: '29\'-7" × 6\'-0"' }, ], 'EF-PENT': [ { name: 'Living', dim: '25\'-8" × 16\'-9"' }, { name: 'Dining', dim: '15\'-4" × 8\'-3"' }, { name: 'Master Bedroom', dim: '20\'-7" × 11\'-0"' }, { name: 'Dressing (Master)', dim: '5\'-6" × 5\'-0"' }, { name: 'Bedroom 1', dim: '13\'-1" × 13\'-2"' }, { name: 'Bedroom 2', dim: '17\'-3" × 11\'-0"' }, { name: 'Bedroom 3', dim: '17\'-3" × 11\'-0"' }, { name: 'Kitchen', dim: '10\'-0" × 18\'-10"' }, { name: 'Servant', dim: '11\'-7" × 5\'-7"' }, { name: 'Foyer', dim: '13\'-5" × 4\'-11"' }, ], }; return schedules[code] || []; } window.Residences = Residences;