// The Universe · MASTER PLAN — interactive site explorer (matches the approved // "Module 03" layout). Full-bleed plan, switchable views (3D massing · 2D site · // ground/arrival), black tower pins with gold live-availability tags, a Key // Section + Legend panel, 2D/3D + day-night toggles, compass / zoom / fullscreen, // and tap-a-tower → cinematic zoom-in + info, with a bridge to live Inventory. const MP_W = 2560, MP_H = 1600; const MP_CAL = false; const HEADER_H = 132; const MP_VIEWS = { '3d': { label:'Master Plan', img:'assets/masterplan/universe-masterplan.jpg', kind:'towers' }, '2d': { label:'2D Site Plan', img:'assets/masterplan/universe-mp-2d.jpg', kind:'towers' }, 'ground': { label:'Ground & Arrival',img:'assets/masterplan/universe-mp-ground.jpg', kind:'places' }, }; const MP_VIEW_ORDER = ['3d','2d','ground']; // Default framing per view — zoom/pan applied on entry so each plan fills the // screen nicely (the 2D/ground site plans have empty setback margins otherwise). const MP_VIEW_HOME = { // 3D master plan render carries an empty dark margin on its left; nudge the // framing right + a touch of zoom so the tower cluster sits centred and the // canvas reads full (no dead void) at BOTH 16:10 and the taller 4:3 iPad. '3d': { zoom:1.22, pan:{ x:-200, y:0 } }, '2d': { zoom:1.04, pan:{ x:120, y:0 } }, 'ground': { zoom:1.30, pan:{ x:-150, y:48 } }, }; const MP_TOWER_PT = { // Master Plan view uses the real top-down site plan (same page-17 geometry as 2D) '3d': { 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} }, '2d': { 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 MP_GROUND_PLACES = [ { id:'g-retail', kind:'retail', x:1623,y:320, label:'High-Street Retail', sub:'47 shops · G+1', level:'GF + FF', detail:['Street-facing retail arcade along the frontage','25 shops on GF + 22 on first floor','Merged corner shops'] }, { id:'g-aramp', kind:'parking', x:1430,y:547, label:'Apartment Ramp', sub:'GF → B3', level:'Ramp', detail:['Resident parking ramp from ground floor to basement 3','Four levels of parking — 3 basements + GF'] }, { id:'g-foyer', kind:'entry', x:1324,y:660, label:'Main Arrival Court', sub:'Double-height foyer', level:'GF', detail:['Primary arrival & drop-off court','Double-height foyers for blocks C–J','Entrance water feature'] }, { id:'g-rramp', kind:'parking', x:1815,y:769, label:'Retail Ramp', sub:'GF → B1', level:'Ramp', detail:['Dedicated retail parking ramp from GF to basement 1','Keeps shopper & resident traffic separate'] }, { id:'g-podium', kind:'parking', x:1238,y:1080, label:'Podium Ramp', sub:'GF → Podium-01', level:'Ramp', detail:['Vehicle ramp from ground floor up to the podium level','Lifts residents above the retail street'] }, ]; const PLACE_ACCENT = { retail:'#e8dd9a', parking:'#8a8a8a', entry:'#e0a86a' }; const PLACE_GLYPH = { retail:'M5 9 H19 L18 19 H6 Z M8 9 V7 A4 4 0 0 1 16 7 V9', parking:'M8 19 V6 H13 A4 4 0 0 1 13 14 H8', entry:'M7 20 V7 H17 V20 M7 7 L12 4 L17 7', }; // Zoning legend (from the deck's ground-floor zoning key) const MP_LEGEND = [ { label:'Basketball Court', color:'#9bbf7a' }, { label:'Amenities', color:'#c98ec9' }, { label:'Substation', color:'#4fb3a8' }, { label:'Residential', color:'#e0a86a' }, { label:'Meter Room', color:'#8a8a8a' }, { label:'Retail Shops', color:'#e8dd9a' }, ]; const AVAIL_LEGEND = [ { id:'available', label:'AVAILABLE', color:'#7bb661' }, { id:'hold', label:'ON HOLD', color:'#d99a2b' }, { id:'sold', label:'SOLD', color:'#a9a29a' }, ]; const GLASS_D = { background:'linear-gradient(155deg, rgba(20,17,12,0.78) 0%, rgba(12,10,7,0.74) 100%)', border:'1px solid rgba(255,248,230,0.26)', backdropFilter:'blur(20px) saturate(1.4)', WebkitBackdropFilter:'blur(20px) saturate(1.4)', boxShadow:'0 14px 36px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.2)', }; const GLASS_L = { background:'rgba(255,253,247,0.86)', border:'1px solid rgba(120,98,54,0.16)', backdropFilter:'blur(20px) saturate(1.2)', WebkitBackdropFilter:'blur(20px) saturate(1.2)', boxShadow:'0 16px 40px rgba(60,44,18,0.16), inset 0 1px 0 rgba(255,255,255,0.7)', }; const pillBtn = { ...GLASS_L, borderRadius:100, cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center' }; function ensureMpKeys(){ if (typeof document==='undefined' || document.getElementById('uni-mpm-keys')) return; const s=document.createElement('style'); s.id='uni-mpm-keys'; s.textContent=`@keyframes mpmPanel{0%{opacity:0;transform:translateX(34px)}100%{opacity:1;transform:translateX(0)}} @keyframes mpmFade{0%{opacity:0}100%{opacity:1}} @keyframes mpmPop{0%{opacity:0;transform:translateY(8px) scale(.96)}100%{opacity:1;transform:translateY(0) scale(1)}}`; document.head.appendChild(s); } function MasterPlan(){ ensureMpKeys(); const t = useLoop(); const t0 = React.useRef(t).current; const [view, setView] = React.useState('3d'); const [night, setNight] = React.useState(false); const [sel, setSel] = React.useState(null); // tower id or place id const [hover, setHover] = React.useState(null); const [chooser, setChooser] = React.useState(false); const [zoom, setZoom] = React.useState(MP_VIEW_HOME['3d'].zoom); const [pan, setPan] = React.useState(MP_VIEW_HOME['3d'].pan); const [hintGone, setHintGone] = React.useState(false); const boxRef = React.useRef(null); const g = React.useRef({ active:false, mode:null, startD:0, startZoom:1, sx:0, sy:0, px:0, py:0, midX:0, midY:0, lastTap:0, ltx:0, lty:0 }); const summaries = React.useMemo(()=>{ const m={}; TOWERS.forEach(tw=>{ m[tw.id]=towerSummary(tw.id); }); return m; },[]); const totals = React.useMemo(()=>TOWERS.reduce((a,tw)=>{ const s=summaries[tw.id]; a.available+=s.available;a.sold+=s.sold;a.hold+=s.hold;a.total+=s.total;return a;},{available:0,sold:0,hold:0,total:0}),[summaries]); const cur = MP_VIEWS[view]; const towerPts = cur.kind==='towers' ? MP_TOWER_PT[view] : {}; const places = cur.kind==='places' ? MP_GROUND_PLACES : []; const fade=()=>{ if(!hintGone) setHintGone(true); }; React.useEffect(()=>{ Object.values(MP_VIEWS).forEach(v=>{ const im=new Image(); im.src=v.img; }); },[]); const clampPan=(p,z)=>{ const el=boxRef.current; if(!el) return p; const r=el.getBoundingClientRect(); const mx=Math.max(0,((z-1)/2)*r.width), my=Math.max(0,((z-1)/2)*r.height); return {x:Math.max(-mx,Math.min(mx,p.x)),y:Math.max(-my,Math.min(my,p.y))}; }; const zoomTo=(pt,Z=2.5)=>{ setZoom(Z); setPan(clampPan({x:-(pt.x-MP_W/2)*Z, y:-(pt.y-MP_H/2)*Z}, Z)); }; const homeOf=(id)=>MP_VIEW_HOME[id]||MP_VIEW_HOME['3d']; const resetXform=()=>{ const h=homeOf(view); setZoom(h.zoom); setPan(h.pan); }; const switchView=(id)=>{ setChooser(false); if(id===view) return; setSel(null); setHover(null); const h=homeOf(id); setZoom(h.zoom); setPan(h.pan); setView(id); }; const pickTower=(id,pt)=>{ setSel('t:'+id); zoomTo(pt, 2.6); // hand a "cut" of the masterplan (this tower's spot) to the next screen, // so FloorSelect can float a locator medallion showing where it sits. try { window.UNI_MP_FOCUS = { towerId:id, img:cur.img, xPct:(pt.x/MP_W)*100, yPct:(pt.y/MP_H)*100 }; } catch(e){} }; const pickPlace=(p)=>{ setSel('p:'+p.id); zoomTo(p, 2.2); }; const closePanel=()=>{ setSel(null); resetXform(); }; const onTouchStart=(e)=>{ const tc=e.touches, el=boxRef.current, r=el?el.getBoundingClientRect():{left:0,top:0}; if(tc.length===2){ const[a,b]=[tc[0],tc[1]]; g.current.mode='pinch'; g.current.active=true; g.current.startD=Math.hypot(b.clientX-a.clientX,b.clientY-a.clientY)||1; g.current.startZoom=zoom; g.current.px=pan.x; g.current.py=pan.y; g.current.midX=(a.clientX+b.clientX)/2-r.left; g.current.midY=(a.clientY+b.clientY)/2-r.top; fade(); } else if(tc.length===1){ const now=performance.now(), p0=tc[0]; if(now-g.current.lastTap<320 && Math.hypot(p0.clientX-g.current.ltx,p0.clientY-g.current.lty)<40){ resetXform(); g.current.lastTap=0; g.current.active=false; fade(); return; } g.current.lastTap=now; g.current.ltx=p0.clientX; g.current.lty=p0.clientY; if(zoom>1){ g.current.mode='pan'; g.current.active=true; g.current.sx=p0.clientX; g.current.sy=p0.clientY; g.current.px=pan.x; g.current.py=pan.y; fade(); } else { g.current.mode=null; g.current.active=false; } } }; const onTouchMove=(e)=>{ if(!g.current.active) return; if(e.cancelable) e.preventDefault(); const tc=e.touches, el=boxRef.current, r=el?el.getBoundingClientRect():{left:0,top:0,width:1,height:1}; if(g.current.mode==='pinch'&&tc.length===2){ const[a,b]=[tc[0],tc[1]]; const nd=Math.hypot(b.clientX-a.clientX,b.clientY-a.clientY)||1; const nz=clamp(g.current.startZoom*(nd/g.current.startD),1,5); const cx=(a.clientX+b.clientX)/2-r.left, cy=(a.clientY+b.clientY)/2-r.top; const k=nz/g.current.startZoom; setZoom(nz); setPan(clampPan({x:(cx-g.current.midX)+g.current.px*k, y:(cy-g.current.midY)+g.current.py*k}, nz)); } else if(g.current.mode==='pan'&&tc.length===1){ const p0=tc[0]; setPan(clampPan({x:g.current.px+(p0.clientX-g.current.sx), y:g.current.py+(p0.clientY-g.current.sy)}, zoom)); } }; const onTouchEnd=(e)=>{ if(e.touches.length===0){ g.current.active=false; g.current.mode=null; } }; const onMouseDown=(e)=>{ if(zoom<=1) return; g.current.mode='mouse'; g.current.active=true; g.current.sx=e.clientX; g.current.sy=e.clientY; g.current.px=pan.x; g.current.py=pan.y; fade(); }; const onMouseMove=(e)=>{ if(!g.current.active||g.current.mode!=='mouse') return; setPan(clampPan({x:g.current.px+(e.clientX-g.current.sx), y:g.current.py+(e.clientY-g.current.sy)}, zoom)); }; const endMouse=()=>{ if(g.current.mode==='mouse'){ g.current.active=false; g.current.mode=null; } }; const onWheel=(e)=>{ if(e.cancelable) e.preventDefault(); fade(); const el=boxRef.current, r=el?el.getBoundingClientRect():{left:0,top:0,width:1,height:1}; const cx=e.clientX-r.left-r.width/2, cy=e.clientY-r.top-r.height/2; const nz=clamp(zoom*(1+(e.deltaY>0?-0.12:0.12)),1,5); const k=nz/zoom; setZoom(nz); setPan(clampPan({x:cx-(cx-pan.x)*k, y:cy-(cy-pan.y)*k}, nz)); }; React.useEffect(()=>{ const el=boxRef.current; if(!el) return; const tm=(e)=>onTouchMove(e), wh=(e)=>onWheel(e); 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,view]); const xform = `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`; const moveT = g.current.active ? 'none' : 'transform 620ms cubic-bezier(0.22,1,0.36,1)'; const k = 1/zoom; const toggleFs=()=>{ const d=document; if(!d.fullscreenElement){ (d.documentElement.requestFullscreen||(()=>{})).call(d.documentElement); } else { (d.exitFullscreen||(()=>{})).call(d); } }; return (