// Floor Selector — step 2 of the inventory drill-down. // params: [towerId]. Back → inventory. // STAGE: the selected tower's elevation study fills the canvas; a glass // FLOOR PICKER floats centre-right beside it. The picker carries: // · header — SELECT A FLOOR · {n} LEVELS · sort toggle // · featured — the Penthouse level as a gold hero card // · the list — every typical floor on a vertical elevator rail, each row a // card with its availability ratio + a live status pill // · legend — Open · Hold · Sold // Tap any floor → navigate('floor//'). Status palette = window.STATUS. // Tower footprints on the top-down master plan (2560×1600) — mirrored from // masterplan.jsx MP_TOWER_PT. Lets the locator "cut" always show the right // tower, even when this screen is reached WITHOUT the master-plan handoff // (e.g. Inventory → Tower → Floors), so the piece never goes missing. // 2D site plan (clean light background, same footprint geometry as the 3D plan) // reads better as a circular crop than the dark-surround 3D render. const FS_MP_IMG = 'assets/masterplan/universe-mp-2d.jpg'; const FS_TOWER_PT = { E:{x:1305,y:278}, F:{x:1704,y:332}, D:{x:1204,y:560}, G:{x:1867,y:724}, C:{x:1204,y:975}, H:{x:1866,y:1158}, B:{x:752,y:1158}, A:{x:902,y:1158}, J:{x:1300,y:1245}, I:{x:1620,y:1245}, }; const FS_KEYS_ID = 'uni-floorselect-keys-v4'; function ensureFsKeys() { if (typeof document === 'undefined') return; if (document.getElementById(FS_KEYS_ID)) return; const s = document.createElement('style'); s.id = FS_KEYS_ID; s.textContent = ` @keyframes fsRowIn { from { opacity:0; transform: translateX(16px); } to { opacity:1; transform: translateX(0); } } @keyframes fsStageIn { from { opacity:0; transform: scale(1.04); } to { opacity:1; transform: scale(1); } } @keyframes fsBldgFloat { 0%{ transform: translate(-50%,-50%); } 50%{ transform: translate(calc(-50% - 16px),-50%); } 100%{ transform: translate(-50%,-50%); } } @keyframes fsCardIn { from { opacity:0; transform: translateY(14px); } to { opacity:1; transform: translateY(0); } } @keyframes fsLocIn { from { opacity:0; } to { opacity:1; } } @keyframes fsLocFloat{ 0%,100% { transform: translateY(0); } 50% { transform: translateY(-12px); } } @keyframes fsLocPulse{ 0%,100% { box-shadow: 0 0 0 0 rgba(201,160,94,0.5); } 70%,100% { box-shadow: 0 0 0 20px rgba(201,160,94,0); } } .fs-row { transition: border-color 220ms ease, background 220ms ease, box-shadow 240ms ease, transform 160ms cubic-bezier(0.22,1,0.36,1); } .fs-row:active { transform: scale(0.985); } .fs-hero { transition: box-shadow 260ms ease, transform 160ms cubic-bezier(0.22,1,0.36,1); } .fs-hero:active { transform: scale(0.99); } .fs-node { transition: width 240ms cubic-bezier(0.22,1,0.36,1), height 240ms cubic-bezier(0.22,1,0.36,1), border-color 220ms ease, box-shadow 260ms ease; } `; document.head.appendChild(s); } function FloorSelect() { ensureFsKeys(); const t = useLoop(); const e = clamp(t/0.5); const [route] = useRoute(); const [hoverFloor, setHoverFloor] = React.useState(null); const [asc, setAsc] = React.useState(false); // sort direction (default: top floor first) const towerId = route.params && route.params[0]; const tower = TOWERS.find(tw => tw.id === towerId); // NB: every hook must run before any early return — during the 220ms route // transition this screen briefly renders with the *live* (next) route, so // towerId can be absent; a hook after the early return would desync React. const floorData = React.useMemo(() => { if (!tower) return []; return buildFloors(towerId).map(fl => { const units = buildUnits(towerId, fl.floor); const avail = units.filter(u => u.status==='available').length; const hold = units.filter(u => u.status==='hold').length; const total = units.length; return { ...fl, avail, hold, total, soldCount: total - avail - hold }; }); }, [towerId, tower]); if (!tower) { return (
); } const summary = towerSummary(towerId); // Float a locator medallion on the left = a "cut" of this tower's spot on the // master plan, so the floor choice always carries visual context. Prefer the // live hand-off from the master plan (exact view the user just saw); otherwise // derive the cut from the tower's known footprint so it appears on EVERY path. const mpFocus = (typeof window !== 'undefined' && window.UNI_MP_FOCUS && window.UNI_MP_FOCUS.towerId === towerId) ? window.UNI_MP_FOCUS : (FS_TOWER_PT[towerId] ? { towerId, img:FS_MP_IMG, xPct:(FS_TOWER_PT[towerId].x/2560)*100, yPct:(FS_TOWER_PT[towerId].y/1600)*100 } : null); const ph = floorData.find(f => f.kind === 'penthouse'); const typical = floorData.filter(f => f.kind !== 'penthouse'); const rows = asc ? [...typical].reverse() : typical; // Live canvas height (1600 on the 16:10 tablet, up to 1920 on iPad 4:3). // The building (top:0/bottom:0) and picker panel (top:206…bottom:90) already // stretch to fill it; `dens` gently enlarges row/card type so the taller // canvas reads dense and intentional (16:10 stays mathematically unchanged). const CH = (window.UNIVERSE_CANVAS && window.UNIVERSE_CANVAS.H) || 1600; const dens = CH > 1720 ? 1.1 : 1; return (
{/* ── TWIN-TOWER ELEVATION STUDY (sits centre-left, full height, no crop) ── */}
{`${tower.name}{ ev.currentTarget.closest('div').style.opacity=0; }}/>
{/* edge fade → blends the drawing into the matching cream letterbox (no top/bottom cut at any aspect) + a soft cream column behind the centre-right panel */}
{/* ── HEADER (back → inventory) ──────────────────────────── */}
navigate('inventory')}, {label:tower.name}]}/>
{tower.name}
{tower.cluster} · {tower.view}
navigate('home')} onKeyDown={ev=>{ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); navigate('home'); } }} onMouseEnter={ev=>{ ev.currentTarget.style.opacity='0.6'; ev.currentTarget.style.transform='scale(1.03)'; }} onMouseLeave={ev=>{ ev.currentTarget.style.opacity='1'; ev.currentTarget.style.transform='scale(1)'; }} style={{display:'flex', alignItems:'center', gap:22, cursor:'pointer', transformOrigin:'right center', transition:'opacity 200ms ease, transform 200ms ease'}}>
{/* ── editorial caption over the drawing (bottom-left) ── */}
{tower.type.toUpperCase()}
{/* ── FLOATING MASTER-PLAN LOCATOR (left, the "cut" of the tower's spot) ── */} {mpFocus && (() => { const D = 304, ZW = 2000, ZH = ZW * 1600 / 2560; const bgX = D/2 - (mpFocus.xPct/100) * ZW; const bgY = D/2 - (mpFocus.yPct/100) * ZH; return (
SELECTED ON MASTER PLAN
{/* gradient vignette so the centre reads clean */}
{/* centre marker on the tower */}
{towerId}
{tower.name}
{tower.cluster.toUpperCase()}
); })()} {/* ── FLOATING FLOOR PICKER (centre-right, beside the tower) ── */}
{/* header */}
SELECT A FLOOR
{floorData.length} LEVELS
{/* featured penthouse */} {ph && (
)} {/* the elevator-rail list */}
{/* the rail */}
{rows.map((fl, i) => { const on = hoverFloor === fl.floor; // live status: hold > sold > all-open let st, txt; if (fl.avail === 0) { st = STATUS.sold; txt = 'Sold out'; } else if (fl.hold > 0) { st = STATUS.hold; txt = `${fl.hold} hold`; } else if (fl.soldCount > 0) { st = STATUS.sold; txt = `${fl.soldCount} sold`; } else { st = STATUS.available; txt = 'All open'; } return ( ); })}
{/* legend footer */}
{[STATUS.available, STATUS.hold, STATUS.sold].map(s => (
{s.label}
))}
); } function CapStat({ n, label }) { return (
{n}
{label}
); } // Small reusable breadcrumb used across drill-down screens. function Breadcrumb({ crumbs }) { return (
{crumbs.map((c, i) => ( {i>0 && } {c.go && i < crumbs.length-1 ? ( ) : ( {c.label} )} ))}
); } // Compact tower-level availability chip (available / hold / sold). function AvailChip({ summary }) { const items = [ { ...STATUS.available, n: summary.available }, { ...STATUS.hold, n: summary.hold }, { ...STATUS.sold, n: summary.sold }, ]; return (
{items.map(it => (
{it.n}
))}
); } const backBtn = { width:78, height:78, borderRadius:'50%', border:'1.4px solid var(--line)', background:'transparent', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color:'var(--ink)', transition:'all 240ms', }; window.FloorSelect = FloorSelect; window.Breadcrumb = Breadcrumb; window.AvailChip = AvailChip; window.fsBackBtn = backBtn;