/* React bridge for the isolated Three.js viewport. */ function PlannerViewport({ plan, ctx }) { const host = React.useRef(null), engine = React.useRef(null), viewport = React.useRef(null); const expandedRef = React.useRef(false), escapeGuard = React.useRef(0); const [expanded, setExpanded] = React.useState(false), [fallbackExpanded, setFallbackExpanded] = React.useState(false); const [ready, setReady] = React.useState(false), [error, setError] = React.useState(null); const [navigation, setNavigation] = React.useState('orbit'), [fullWalls, setFullWalls] = React.useState(false); const [hover, setHover] = React.useState(''); const [position, setPosition] = React.useState({ x: plan.room.entrance.x + plan.room.entrance.w / 2, z: plan.room.h - .4, yaw: 0 }); const selectedFixture = plan.fixtures.find(f => f.id === ctx.selBay?.fixtureId); const selectedBay = selectedFixture?.bays.find(b => b.id === ctx.selBay?.bayId); const options = plan.fixtures.flatMap(f => f.bays.map((b, i) => ({ value: f.id + '/' + b.id, fixture: f, bay: b, label: f.label + ' · Bay ' + (i + 1) }))); function colorForBay(bay) { if (!bay.cat) return '#b8b7ad'; const s = window.PlannerShared, v = s.bayMetricValue(plan, bay, ctx.metric, ctx.period, ctx.basis); return ctx.metric === 's2s' ? s.heatS2S(v) : s.heatSeq(ctx.range.max > ctx.range.min ? (v - ctx.range.min) / (ctx.range.max - ctx.range.min) : .5); } const config = { plan, ctx, navigation, fullWalls, colorForBay, onHover: setHover, onPosition: setPosition, onError: setError, onExitWalk: () => { // Escape first leaves fullscreen; a second Escape can leave Walk. if (!expandedRef.current && performance.now() > escapeGuard.current) setNavigation('orbit'); } }; async function exitFullscreen() { engine.current?.preserveCameraOnResize(); escapeGuard.current = performance.now() + 500; if (document.fullscreenElement === viewport.current) { try { await document.exitFullscreen(); } catch (_) { /* Browser may already be exiting after Escape. */ } } else { expandedRef.current = false; setExpanded(false); setFallbackExpanded(false); } host.current?.querySelector('canvas')?.focus({ preventScroll: true }); } async function toggleFullscreen() { if (expandedRef.current) { await exitFullscreen(); return; } engine.current?.preserveCameraOnResize(); expandedRef.current = true; setExpanded(true); try { if (!viewport.current.requestFullscreen || !document.fullscreenEnabled) throw new Error('Fullscreen unavailable'); await viewport.current.requestFullscreen(); } catch (_) { // Embedded browsers may disallow native fullscreen. Fill the page instead. setFallbackExpanded(true); } host.current?.querySelector('canvas')?.focus({ preventScroll: true }); } React.useEffect(() => { let wasNative = false; const changed = () => { const active = document.fullscreenElement === viewport.current; engine.current?.preserveCameraOnResize(); if (active) { expandedRef.current = true; setExpanded(true); setFallbackExpanded(false); } else if (wasNative) { escapeGuard.current = performance.now() + 500; expandedRef.current = false; setExpanded(false); setFallbackExpanded(false); host.current?.querySelector('canvas')?.focus({ preventScroll: true }); } wasNative = active; }; const keyboard = e => { if (e.key === 'Escape' && expandedRef.current) { e.preventDefault(); e.stopImmediatePropagation(); exitFullscreen(); } else if (e.key === 'Tab' && expandedRef.current && !document.fullscreenElement) { const items = Array.from(viewport.current.querySelectorAll('button:not(:disabled), select, canvas')); const first = items[0], last = items[items.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); } } }; document.addEventListener('fullscreenchange', changed); window.addEventListener('keydown', keyboard, true); return () => { document.removeEventListener('fullscreenchange', changed); window.removeEventListener('keydown', keyboard, true); }; }, []); React.useEffect(() => { try { if (!window.createPlanner3D) throw new Error('The 3D module could not load. Restart the local preview and refresh.'); engine.current = window.createPlanner3D(host.current, config); setReady(true); } catch (e) { setError(e.message || '3D rendering is unavailable in this browser.'); } return () => { engine.current?.dispose(); engine.current = null; }; }, []); React.useEffect(() => { engine.current?.update(config); }); React.useEffect(() => { if (ctx.view === 'top') setNavigation('orbit'); }, [ctx.view]); const isWalk = navigation === 'walk'; const home = () => { setNavigation('orbit'); engine.current?.preset(ctx.view === 'top' ? 'top' : 'overview'); }; return
{!ready && !error &&
Preparing your store…
} {error &&
3D view unavailable

{error}

} {ready && !error && <>
{isWalk ? 'Walk through your store' : ctx.view === 'top' ? 'Floor overview' : 'Your store, in 3D'}
Sample prototype · approximate scale
{ctx.view !== 'top' &&
} {!isWalk && <> }
{isWalk ? `Drag to look · WASD / arrows to move · ${expanded ? 'Esc exits fullscreen' : 'Esc exits Walk'}` : ctx.view === 'top' ? 'Scroll to zoom · Right-drag to pan · Click a bay' : 'Drag to orbit · Scroll to zoom · Right-drag to pan'}
{hover || (selectedFixture ? `${selectedFixture.label} · ${selectedBay?.cat ? window.plannerCatName(selectedBay.cat) : 'Empty bay'}` : 'Click a shelf bay to explore its category')}
{!isWalk && } {!isWalk && ctx.view !== 'top' && }
{isWalk &&
} {isWalk &&
{plan.rooms.map((r, i) => )} {plan.fixtures.map(f => )} You are here
} }
; } window.PlannerViewport = PlannerViewport;