// G+2 Floors explorer — the screen at #/gplus. // Three tabs (GROUND · FIRST · SECOND); each shows the full podium floor drawing // in a pan / zoom viewer (pointer-drag pan · wheel / pinch zoom · double-tap // reset · +/− buttons) with a scrollable legend rail beside it listing the // verbatim amenity legend for that level. Back → inventory. // Legend text is transcribed verbatim from The Universe Plan.pdf (pages 1–3), // see _build-docs/05-uplan-2026-07-06.md § "G+2 amenity legends". const GP_KEYS_ID = 'uni-gplus-keys-v1'; function ensureGpKeys() { if (typeof document === 'undefined' || document.getElementById(GP_KEYS_ID)) return; const s = document.createElement('style'); s.id = GP_KEYS_ID; s.textContent = ` @keyframes gpFadeUp { from { opacity:0; transform: translateY(20px); } to { opacity:1; transform: translateY(0); } } @keyframes gpRowIn { from { opacity:0; transform: translateX(14px); } to { opacity:1; transform: translateX(0); } } .gp-tab { transition: background 240ms ease, color 240ms ease, border-color 240ms ease, box-shadow 240ms ease; } .gp-tab:active { transform: scale(0.98); } .gp-zoom-btn { transition: background 180ms ease, color 180ms ease, border-color 180ms ease, transform 140ms ease; } .gp-zoom-btn:hover { background: var(--gold) !important; color:#1a130a !important; border-color: var(--gold) !important; } .gp-zoom-btn:active { transform: scale(0.92); } .gp-zoom-btn:disabled { opacity:0.4; cursor:not-allowed; } .gp-legrow { transition: background 200ms ease, border-color 200ms ease, transform 140ms ease; } .gp-legrow.gp-clickable { cursor:pointer; } .gp-legrow.gp-clickable:active { transform: scale(0.99); } .gp-pin-tok { transition: transform 140ms ease; } .gp-pin:active .gp-pin-tok { transform: scale(0.94); } @keyframes gpPinPulse { 0% { transform: scale(1); opacity:0.72; } 70% { transform: scale(1.85); opacity:0; } 100% { transform: scale(1.85); opacity:0; } } `; document.head.appendChild(s); } // Verbatim legends (The Universe Plan.pdf p1–3). const GP_TABS = [ { key:'ground', label:'GROUND', short:'G', img:'assets/plans/gplus-ground.jpg', aspect:'3082 / 3082', groups:[{ items:[ ['01','Double Height Foyer'], ['02','Drop Off'], ['03','Entrance Lobby'], ['04','Basketball Court'], ['05','Water Feature'], ['06','Lawn'], ['07','Seating Garden'], ['08','Step Seating'], ['09','Mound Garden'], ['10','Walking Track'], ['11','Badminton Court'], ]}], }, { key:'first', label:'FIRST', short:'1', img:'assets/plans/gplus-first.jpg', aspect:'3082 / 3081', groups:[ { title:'Podium Amenities', items:[ ['01','Crystal Garden'], ['02','Step Seating'], ['03','Box Cricket'], ['04','Accent Trees'], ['05','Banquet Garden'], ['06','Petal Garden'], ['07','Swimming Pool'], ['08','Jogging Track'], ['09','Outdoor Fitness'], ['10',"Children's Playground"], ['11','Garden Theatre'], ['12','The Grand Stairway'], ]}, { title:'Covered Amenities', items:[ ['13','Guest Rooms'], ['14','Banquet'], ['15','The Lounge'], ['16','Podcast Pod'], ['17','Meeting Room'], ['18','Learning Room'], ['19','The Studio'], ['20','Café Universe'], ['21',"Toddler's Den"], ['22','Indoor Games'], ['23','Gen-z Lounge'], ['24','Restrooms'], ['25','The Wellness Club Lower Level – Strength Training, Yoga, Crossfit'], ['26','Restrooms & Changing Rooms'], ['27','Utility Rooms'], ['28','Society Management Office'], ]}, ], }, { key:'second', label:'SECOND', short:'2', img:'assets/plans/gplus-second.jpg', aspect:'3082 / 3081', groups:[{ items:[ ['01','The Grand Stairway'], ['02','Pickleball Court'], ['03','Garden & Walkway'], ['04','The Wellness Club Upper Level – Cardio, Circuit Training'], ]}], }, ]; // Normalized pin positions — fraction of image width/height — for the small // numbered markers printed on each plan drawing. Authored by inspecting the // three source JPGs (all ≈3082² square). Every legend number is locatable, so // each legend row is focusable. See _build-docs for the derivation. const GP_PINS = { ground: { '01':[0.562,0.302], '02':[0.558,0.329], '03':[0.497,0.292], '04':[0.388,0.795], '05':[0.210,0.780], '06':[0.273,0.795], '07':[0.251,0.851], '08':[0.262,0.769], '09':[0.312,0.776], '10':[0.320,0.929], '11':[0.429,0.283], }, first: { '01':[0.586,0.570], '02':[0.508,0.460], '03':[0.425,0.638], '04':[0.612,0.632], '05':[0.689,0.709], '06':[0.641,0.528], '07':[0.573,0.410], '08':[0.676,0.440], '09':[0.406,0.326], '10':[0.514,0.588], '11':[0.573,0.492], '12':[0.718,0.360], '13':[0.736,0.619], '14':[0.642,0.697], '15':[0.515,0.698], '16':[0.580,0.687], '17':[0.581,0.703], '18':[0.568,0.723], '19':[0.515,0.722], '20':[0.440,0.702], '21':[0.441,0.592], '22':[0.442,0.475], '23':[0.415,0.463], '24':[0.441,0.432], '25':[0.562,0.463], '26':[0.540,0.369], '27':[0.698,0.320], '28':[0.788,0.630], }, second: { '01':[0.723,0.360], '02':[0.773,0.319], '03':[0.741,0.291], '04':[0.581,0.452], }, }; function GPlus() { ensureGpKeys(); const t = useLoop(); const e = clamp(t/0.5); const [tab, setTab] = React.useState('ground'); const active = GP_TABS.find(x => x.key === tab) || GP_TABS[0]; // ── pan / zoom state (reset on tab change) ────────────────────── const [zoom, setZoom] = React.useState(1); const [pan, setPan] = React.useState({ x:0, y:0 }); const [hintFaded, setHintFaded] = React.useState(false); const [focus, setFocus] = React.useState(null); // active pin/legend item number, or null const containerRef = React.useRef(null); // gesture frame const wrapperRef = React.useRef(null); // contain-fit transformed layer const focusRef = React.useRef(null); focusRef.current = focus; // keep in sync for gesture closures const pinMap = GP_PINS[active.key] || {}; const g = 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, }); React.useEffect(() => { setZoom(1); setPan({x:0,y:0}); setFocus(null); }, [tab]); const fadeHint = () => { if (!hintFaded) setHintFaded(true); }; // When a pin focus is active, relax the clamp so edge pins can reach centre // (default relax follows the current focus state). const clampPan = (p, z, relax) => { const el = containerRef.current; if (!el) return p; const r = el.getBoundingClientRect(); const loose = relax === undefined ? !!focusRef.current : relax; const maxX = loose ? (z/2)*r.width + 140 : Math.max(0, ((z-1)/2)*r.width) + 80; const maxY = loose ? (z/2)*r.height + 140 : 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 resetView = () => { setZoom(1); setPan({x:0,y:0}); setFocus(null); focusRef.current=null; }; // Fly the plan so the numbered pin lands at the centre of the viewer at a // comfortable zoom (keep the current zoom if the user is already deeper). const focusItem = (n) => { if (!pinMap[n]) return; if (focus === n) { resetView(); return; } // toggle off → fit view const wrap = wrapperRef.current; if (!wrap) return; const W = wrap.offsetWidth, H = wrap.offsetHeight; // laid-out contain-fit size (unscaled) const tz = Math.max(zoom, 2.1); const [fx, fy] = pinMap[n]; const dx = (fx - 0.5) * W, dy = (fy - 0.5) * H; focusRef.current = n; setFocus(n); try{ if(window.RDA){ var _lbl=null; (active.groups||[]).forEach(function(gp){ (gp.items||[]).forEach(function(it){ if(it[0]===n) _lbl=it[1]; }); }); window.RDA.track('amenity_focus', _lbl||n, 1, {floor: active.key, num: n}); } }catch(e){} setZoom(tz); setPan(clampPan({ x: -dx*tz, y: -dy*tz }, tz, true)); fadeHint(); }; const onTouchStart = (ev) => { const c = g.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; c.mode='pinch'; c.active=true; c.startD=Math.hypot(dx,dy)||1; c.startZoom=zoom; c.startPanX=pan.x; c.startPanY=pan.y; c.startMidX=(a.clientX+b.clientX)/2-rect.left; c.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; if (now-c.lastTapAt < 320 && Math.hypot(tx-c.lastTapX, ty-c.lastTapY) < 40) { resetView(); c.lastTapAt=0; c.active=false; c.mode=null; fadeHint(); return; } c.lastTapAt=now; c.lastTapX=tx; c.lastTapY=ty; if (zoom > 1) { c.mode='pan-touch'; c.active=true; c.dragStartX=tx; c.dragStartY=ty; c.startPanX=pan.x; c.startPanY=pan.y; fadeHint(); } else { c.mode=null; c.active=false; } } }; const onTouchMove = (ev) => { const c = g.current; if (!c.active) return; if (ev.cancelable) ev.preventDefault(); const touches = ev.touches; if (c.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/c.startD; const newZoom=Math.max(1, Math.min(5, c.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/c.startZoom; const offX=(cx-c.startMidX)+c.startPanX*k; const offY=(cy-c.startMidY)+c.startPanY*k; setZoom(newZoom); setPan(clampPan({x:offX,y:offY}, newZoom)); } else if (c.mode==='pan-touch' && touches.length===1) { const t0=touches[0]; const dx=t0.clientX-c.dragStartX; const dy=t0.clientY-c.dragStartY; setPan(clampPan({x:c.startPanX+dx, y:c.startPanY+dy}, zoom)); } }; const onTouchEnd = (ev) => { const c = g.current; if (ev.touches.length===0) { c.active=false; c.mode=null; } else if (ev.touches.length===1 && c.mode==='pinch') { const t0=ev.touches[0]; c.mode = zoom>1?'pan-touch':null; c.active = c.mode==='pan-touch'; c.dragStartX=t0.clientX; c.dragStartY=t0.clientY; c.startPanX=pan.x; c.startPanY=pan.y; } }; const onMouseDown = (ev) => { if (zoom<=1) return; const c=g.current; c.mode='pan-mouse'; c.active=true; c.dragStartX=ev.clientX; c.dragStartY=ev.clientY; c.startPanX=pan.x; c.startPanY=pan.y; fadeHint(); }; const onMouseMove = (ev) => { const c=g.current; if (!c.active || c.mode!=='pan-mouse') return; const dx=ev.clientX-c.dragStartX, dy=ev.clientY-c.dragStartY; setPan(clampPan({x:c.startPanX+dx, y:c.startPanY+dy}, zoom)); }; const endMouse = () => { const c=g.current; if (c.mode==='pan-mouse'){ c.active=false; c.mode=null; } }; const onWheel = (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.12:0.12; const newZoom=Math.max(1, Math.min(5, zoom*(1+delta))); const k=newZoom/zoom; setZoom(newZoom); setPan(clampPan({x:cx-(cx-pan.x)*k, y:cy-(cy-pan.y)*k}, newZoom)); }; React.useEffect(() => { const el=containerRef.current; if (!el) return; const tm=(ev)=>onTouchMove(ev); const wh=(ev)=>onWheel(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, tab]); const step = (dir) => { fadeHint(); setZoom(z => { const nz = dir>0 ? Math.min(5, +(z*1.25).toFixed(3)) : Math.max(1, +(z/1.25).toFixed(3)); if (nz <= 1) setPan({x:0,y:0}); return nz; }); }; const RAIL_W = 600; const railRight = RAIL_W + 72 + 48; // rail width + right inset + gutter return (