diff --git a/apps/web/src/components/simulation/SimulationCanvas.tsx b/apps/web/src/components/simulation/SimulationCanvas.tsx new file mode 100644 index 000000000..250040755 --- /dev/null +++ b/apps/web/src/components/simulation/SimulationCanvas.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { cn } from "~/lib/utils"; +import { useSimulationViewerStore } from "~/simulationViewerStore"; + +/** + * The main simulation rendering surface. + * + * Uses a `` element scaled to fill the available space. A + * lightweight demo scene is drawn (grid + bouncing entities) so the + * viewer is immediately usable before a real simulation backend is + * wired in. + */ +export function SimulationCanvas({ className }: { className?: string }) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animFrameRef = useRef(0); + + const zoom = useSimulationViewerStore((s) => s.zoom); + const showGrid = useSimulationViewerStore((s) => s.showGrid); + const showEntityLabels = useSimulationViewerStore((s) => s.showEntityLabels); + const playbackState = useSimulationViewerStore((s) => s.playbackState); + const entities = useSimulationViewerStore((s) => s.entities); + const selectedEntityId = useSimulationViewerStore((s) => s.selectedEntityId); + const selectEntity = useSimulationViewerStore((s) => s.selectEntity); + + // ── Resize canvas to fill container ──────────────────────────────── + const syncSize = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + const dpr = window.devicePixelRatio || 1; + const { width, height } = container.getBoundingClientRect(); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + }, []); + + useEffect(() => { + syncSize(); + const observer = + typeof ResizeObserver !== "undefined" && containerRef.current + ? new ResizeObserver(syncSize) + : null; + if (observer && containerRef.current) observer.observe(containerRef.current); + return () => observer?.disconnect(); + }, [syncSize]); + + // ── Render loop ──────────────────────────────────────────────────── + useEffect(() => { + let running = true; + + const draw = () => { + if (!running) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = canvas.width / dpr; + const h = canvas.height / dpr; + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + // Apply zoom + ctx.save(); + const cx = w / 2; + const cy = h / 2; + ctx.translate(cx, cy); + ctx.scale(zoom, zoom); + ctx.translate(-cx, -cy); + + // ── Grid ────────────────────────────────────────────────── + if (showGrid) { + const gridSize = 40; + ctx.strokeStyle = "rgba(128,128,128,0.12)"; + ctx.lineWidth = 0.5; + ctx.beginPath(); + for (let x = 0; x <= w; x += gridSize) { + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + } + for (let y = 0; y <= h; y += gridSize) { + ctx.moveTo(0, y); + ctx.lineTo(w, y); + } + ctx.stroke(); + + // Axis cross-hairs at center + ctx.strokeStyle = "rgba(128,128,128,0.25)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx, 0); + ctx.lineTo(cx, h); + ctx.moveTo(0, cy); + ctx.lineTo(w, cy); + ctx.stroke(); + } + + // ── Entities ────────────────────────────────────────────── + for (const entity of entities) { + const isSelected = entity.id === selectedEntityId; + const radius = 8; + + // Body + ctx.fillStyle = isSelected ? "hsl(217, 91%, 60%)" : "hsl(217, 70%, 55%)"; + ctx.beginPath(); + ctx.arc(entity.x, entity.y, radius, 0, Math.PI * 2); + ctx.fill(); + + // Selection ring + if (isSelected) { + ctx.strokeStyle = "hsl(217, 91%, 75%)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(entity.x, entity.y, radius + 4, 0, Math.PI * 2); + ctx.stroke(); + } + + // Label + if (showEntityLabels) { + ctx.fillStyle = "rgba(255,255,255,0.85)"; + ctx.font = "11px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(entity.label, entity.x, entity.y - radius - 6); + } + } + + // ── Paused overlay ──────────────────────────────────────── + if (playbackState === "paused") { + ctx.fillStyle = "rgba(0,0,0,0.25)"; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = "rgba(255,255,255,0.7)"; + ctx.font = "bold 16px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("PAUSED", cx, cy); + } + + // ── Stopped placeholder ─────────────────────────────────── + if (playbackState === "stopped" && entities.length === 0) { + ctx.fillStyle = "rgba(255,255,255,0.25)"; + ctx.font = "14px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Press play to start simulation", cx, cy); + } + + ctx.restore(); + + animFrameRef.current = requestAnimationFrame(draw); + }; + + animFrameRef.current = requestAnimationFrame(draw); + + return () => { + running = false; + cancelAnimationFrame(animFrameRef.current); + }; + }, [zoom, showGrid, showEntityLabels, playbackState, entities, selectedEntityId]); + + // ── Click-to-select entities ─────────────────────────────────────── + const handleClick = useCallback( + (event: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const hitRadius = 14; + + for (const entity of entities) { + const dx = entity.x - x; + const dy = entity.y - y; + if (Math.sqrt(dx * dx + dy * dy) <= hitRadius) { + selectEntity(entity.id); + return; + } + } + selectEntity(null); + }, + [entities, selectEntity], + ); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/simulation/SimulationControls.tsx b/apps/web/src/components/simulation/SimulationControls.tsx new file mode 100644 index 000000000..71098915e --- /dev/null +++ b/apps/web/src/components/simulation/SimulationControls.tsx @@ -0,0 +1,327 @@ +import { useCallback } from "react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + GridIcon, + PauseIcon, + PlayIcon, + RotateCcwIcon, + SkipBackIcon, + SquareIcon, + TagIcon, + ZoomInIcon, + ZoomOutIcon, +} from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { useSimulationViewerStore } from "~/simulationViewerStore"; +import { Button } from "~/components/ui/button"; +import { Separator } from "~/components/ui/separator"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +const SPEED_STEPS = [0.25, 0.5, 1, 2, 4] as const; + +function SpeedLabel({ speed }: { speed: number }) { + return ( + + {speed}x + + ); +} + +/** + * Compact playback and viewport controls bar. + * + * Two layout modes: + * - **inline** (≥ tablet): horizontal bar that sits below the canvas toolbar + * - **drawer** (mobile): vertical strip rendered in a bottom sheet + */ +export function SimulationControls({ + layout = "inline", + className, +}: { + layout?: "inline" | "drawer"; + className?: string; +}) { + const playbackState = useSimulationViewerStore((s) => s.playbackState); + const speed = useSimulationViewerStore((s) => s.speed); + const currentTick = useSimulationViewerStore((s) => s.currentTick); + const maxTick = useSimulationViewerStore((s) => s.maxTick); + const zoom = useSimulationViewerStore((s) => s.zoom); + const showGrid = useSimulationViewerStore((s) => s.showGrid); + const showEntityLabels = useSimulationViewerStore((s) => s.showEntityLabels); + + const play = useSimulationViewerStore((s) => s.play); + const pause = useSimulationViewerStore((s) => s.pause); + const stop = useSimulationViewerStore((s) => s.stop); + const setSpeed = useSimulationViewerStore((s) => s.setSpeed); + const setTick = useSimulationViewerStore((s) => s.setTick); + const setZoom = useSimulationViewerStore((s) => s.setZoom); + const toggleGrid = useSimulationViewerStore((s) => s.toggleGrid); + const toggleEntityLabels = useSimulationViewerStore((s) => s.toggleEntityLabels); + const reset = useSimulationViewerStore((s) => s.reset); + + const cycleSpeed = useCallback( + (direction: 1 | -1) => { + const idx = SPEED_STEPS.indexOf(speed as (typeof SPEED_STEPS)[number]); + const nextIdx = Math.max( + 0, + Math.min(SPEED_STEPS.length - 1, (idx === -1 ? 2 : idx) + direction), + ); + setSpeed(SPEED_STEPS[nextIdx]!); + }, + [speed, setSpeed], + ); + + const isVertical = layout === "drawer"; + + return ( +
+ {/* ── Playback ───────────────────────────────────────────────── */} +
+ + setTick(0)} + /> + } + > + + + Reset to start + + + {playbackState === "playing" ? ( + + + } + > + + + Pause + + ) : ( + + + } + > + + + Play + + )} + + + + } + > + + + Stop + +
+ + + + {/* ── Speed ──────────────────────────────────────────────────── */} +
+ + + +
+ + + + {/* ── Timeline scrubber ──────────────────────────────────────── */} +
+ + {currentTick} + + setTick(Number(e.target.value))} + className={cn( + "h-1 flex-1 cursor-pointer appearance-none rounded-full bg-muted/50 accent-primary [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-sm", + isVertical && "w-full", + )} + aria-label="Timeline" + /> + + {maxTick} + +
+ + + + {/* ── Zoom ───────────────────────────────────────────────────── */} +
+ + setZoom(Math.max(0.25, zoom - 0.25))} + disabled={zoom <= 0.25} + /> + } + > + + + Zoom out + + + {Math.round(zoom * 100)}% + + + setZoom(Math.min(4, zoom + 0.25))} + disabled={zoom >= 4} + /> + } + > + + + Zoom in + +
+ + + + {/* ── Overlay toggles ────────────────────────────────────────── */} +
+ + + } + > + + + {showGrid ? "Hide grid" : "Show grid"} + + + + } + > + + + {showEntityLabels ? "Hide labels" : "Show labels"} + + + + } + > + + + Reset simulation + +
+
+ ); +} diff --git a/apps/web/src/components/simulation/SimulationInspector.tsx b/apps/web/src/components/simulation/SimulationInspector.tsx new file mode 100644 index 000000000..5b3186240 --- /dev/null +++ b/apps/web/src/components/simulation/SimulationInspector.tsx @@ -0,0 +1,297 @@ +import { ActivityIcon, BoxIcon, ClockIcon, SlidersHorizontalIcon, XIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { type InspectorTab, useSimulationViewerStore } from "~/simulationViewerStore"; +import { Button } from "~/components/ui/button"; +import { Separator } from "~/components/ui/separator"; + +const TABS: { id: InspectorTab; label: string; Icon: typeof SlidersHorizontalIcon }[] = [ + { id: "parameters", label: "Params", Icon: SlidersHorizontalIcon }, + { id: "entities", label: "Entities", Icon: BoxIcon }, + { id: "timeline", label: "Timeline", Icon: ClockIcon }, + { id: "metrics", label: "Metrics", Icon: ActivityIcon }, +]; + +// ─── Parameter Slider ──────────────────────────────────────────────── +function ParameterRow({ + param, +}: { + param: { + id: string; + label: string; + value: number; + min: number; + max: number; + step: number; + unit?: string; + }; +}) { + const updateParameter = useSimulationViewerStore((s) => s.updateParameter); + + return ( + + ); +} + +// ─── Entity List ───────────────────────────────────────────────────── +function EntityListPanel() { + const entities = useSimulationViewerStore((s) => s.entities); + const selectedEntityId = useSimulationViewerStore((s) => s.selectedEntityId); + const selectEntity = useSimulationViewerStore((s) => s.selectEntity); + + if (entities.length === 0) { + return ( +
+ No entities in the simulation yet. Start the simulation to see entities appear. +
+ ); + } + + return ( +
+ {entities.map((entity) => ( + + ))} + + {/* Selected entity detail */} + {selectedEntityId && + (() => { + const entity = entities.find((e) => e.id === selectedEntityId); + if (!entity) return null; + return ( + <> + +
+

{entity.label}

+

Type: {entity.type}

+ {Object.entries(entity.properties).map(([key, val]) => ( +

+ {key}: {String(val)} +

+ ))} +
+ + ); + })()} +
+ ); +} + +// ─── Timeline Panel ────────────────────────────────────────────────── +function TimelinePanel() { + const currentTick = useSimulationViewerStore((s) => s.currentTick); + const maxTick = useSimulationViewerStore((s) => s.maxTick); + const speed = useSimulationViewerStore((s) => s.speed); + const playbackState = useSimulationViewerStore((s) => s.playbackState); + + return ( +
+
+ + + + +
+
+

Progress

+
+
0 ? (currentTick / maxTick) * 100 : 0}%` }} + /> +
+

+ {maxTick > 0 ? Math.round((currentTick / maxTick) * 100) : 0}% +

+
+
+ ); +} + +// ─── Metrics Panel ─────────────────────────────────────────────────── +function MetricsPanel() { + const metrics = useSimulationViewerStore((s) => s.metrics); + + if (metrics.length === 0) { + return ( +
+ No metrics recorded yet. Metrics will appear as the simulation runs. +
+ ); + } + + // Show latest snapshot + const latest = metrics[metrics.length - 1]!; + const entries = Object.entries(latest.values); + + return ( +
+

+ Tick {latest.tick} · {metrics.length} snapshots +

+ {entries.map(([key, val]) => ( +
+ {key} + + {typeof val === "number" ? val.toFixed(2) : String(val)} + +
+ ))} +
+ ); +} + +function InfoCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +// ─── Parameters Panel ──────────────────────────────────────────────── +function ParametersPanel() { + const parameters = useSimulationViewerStore((s) => s.parameters); + + if (parameters.length === 0) { + return ( +
+ No parameters configured. Parameters will appear when a simulation is loaded. +
+ ); + } + + return ( +
+ {parameters.map((param) => ( + + ))} +
+ ); +} + +// ─── Tab Content Router ────────────────────────────────────────────── +function InspectorTabContent({ tab }: { tab: InspectorTab }) { + switch (tab) { + case "parameters": + return ; + case "entities": + return ; + case "timeline": + return ; + case "metrics": + return ; + } +} + +// ─── Main Inspector Component ──────────────────────────────────────── +/** + * Side panel or bottom-drawer inspector for the simulation viewer. + * + * Layouts: + * - **sidebar** (desktop/wide/ultrawide): fixed-width right panel + * - **bottom** (tablet): horizontal bottom strip + * - **sheet** (mobile): full-screen sheet overlay + */ +export function SimulationInspector({ + layout = "sidebar", + className, + onClose, +}: { + layout?: "sidebar" | "bottom" | "sheet"; + className?: string; + onClose?: () => void; +}) { + const inspectorTab = useSimulationViewerStore((s) => s.inspectorTab); + const setInspectorTab = useSimulationViewerStore((s) => s.setInspectorTab); + + return ( +
+ {/* Header */} +
+
+ {TABS.map((tab) => ( + + ))} +
+ {onClose && ( + + )} +
+ + {/* Content */} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/simulation/SimulationViewer.tsx b/apps/web/src/components/simulation/SimulationViewer.tsx new file mode 100644 index 000000000..24393b629 --- /dev/null +++ b/apps/web/src/components/simulation/SimulationViewer.tsx @@ -0,0 +1,406 @@ +import { useCallback } from "react"; +import { + GaugeIcon, + LaptopIcon, + MaximizeIcon, + MonitorIcon, + PanelRightCloseIcon, + PanelRightOpenIcon, + SmartphoneIcon, + TabletIcon, + XIcon, +} from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { + type SimulationViewportPreset, + SIMULATION_VIEWPORT_PRESETS, + useSimulationViewerStore, +} from "~/simulationViewerStore"; +import { useMediaQuery, useIsMobile } from "~/hooks/useMediaQuery"; + +import { Button } from "~/components/ui/button"; +import { + Menu, + MenuGroupLabel, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "~/components/ui/menu"; +import { Sheet, SheetPopup } from "~/components/ui/sheet"; + +import { SimulationCanvas } from "./SimulationCanvas"; +import { SimulationControls } from "./SimulationControls"; +import { SimulationInspector } from "./SimulationInspector"; + +// ─── Viewport Preset Icons ────────────────────────────────────────── +const PRESET_ICONS: Record = { + mobile: SmartphoneIcon, + tablet: TabletIcon, + laptop: LaptopIcon, + desktop: MonitorIcon, + wide: MonitorIcon, + ultrawide: MonitorIcon, +}; + +const RESPONSIVE_VALUE = "__responsive__"; + +// ─── Breakpoint Thresholds ────────────────────────────────────────── +// +// These govern which *layout shell* is rendered. They are independent +// of the viewport-preset selector which controls the simulation +// coordinate space. +// +// Layout modes: +// mobile (<640px) – stacked, sheet-based inspector, FAB controls +// tablet (640–1023) – stacked with bottom inspector strip +// laptop (1024–1535) – side inspector, inline controls +// desktop+ (≥1536) – wider inspector, more breathing room +// +const TABLET_QUERY = "sm"; +const LAPTOP_QUERY = "lg"; +const DESKTOP_QUERY = "2xl"; +const WIDE_QUERY = "4xl"; + +// ─── Toolbar ──────────────────────────────────────────────────────── +function SimulationToolbar({ onClose }: { onClose: () => void }) { + const viewportPreset = useSimulationViewerStore((s) => s.viewportPreset); + const setViewportPreset = useSimulationViewerStore((s) => s.setViewportPreset); + const inspectorOpen = useSimulationViewerStore((s) => s.inspectorOpen); + const toggleInspector = useSimulationViewerStore((s) => s.toggleInspector); + const playbackState = useSimulationViewerStore((s) => s.playbackState); + + const preset = viewportPreset + ? SIMULATION_VIEWPORT_PRESETS.find((p) => p.id === viewportPreset) + : null; + const PresetIcon = viewportPreset ? PRESET_ICONS[viewportPreset] : null; + + return ( +
+ {/* Left: Title + status */} +
+ + Simulation + {playbackState !== "stopped" && ( + + + {playbackState === "playing" ? "Running" : "Paused"} + + )} +
+ + {/* Right: Actions */} +
+ {/* Viewport preset picker */} + + + {PresetIcon ? : } + {preset ? preset.label : "Responsive"} + + + Simulation Viewport + { + setViewportPreset( + value === RESPONSIVE_VALUE ? null : (value as SimulationViewportPreset), + ); + }} + > + + + + Responsive + + + + {SIMULATION_VIEWPORT_PRESETS.map((p) => { + const Icon = PRESET_ICONS[p.id]; + return ( + + + + {p.label} + + {p.width}×{p.height} + + + + ); + })} + + + + + {/* Toggle inspector */} + + + {/* Close */} + +
+
+ ); +} + +// ─── Mobile Layout ────────────────────────────────────────────────── +// +// Full-screen stacked layout. Canvas fills available space. +// Controls in a bottom bar; inspector opens as a sheet. +// +function MobileLayout({ onClose }: { onClose: () => void }) { + const inspectorOpen = useSimulationViewerStore((s) => s.inspectorOpen); + const toggleInspector = useSimulationViewerStore((s) => s.toggleInspector); + const controlsDrawerOpen = useSimulationViewerStore((s) => s.controlsDrawerOpen); + const setControlsDrawerOpen = useSimulationViewerStore((s) => s.setControlsDrawerOpen); + + return ( +
+ + + + {/* Bottom controls bar */} +
+ +
+ + {/* Floating inspector toggle for mobile */} + + + {/* Inspector as sheet */} + { + if (!open) toggleInspector(); + }} + > + + + + + + {/* Controls drawer (if needed on very small screens) */} + setControlsDrawerOpen(open)}> + +
+ +
+
+
+
+ ); +} + +// ─── Tablet Layout ────────────────────────────────────────────────── +// +// Canvas on top, controls inline, inspector as a bottom strip. +// +function TabletLayout({ onClose }: { onClose: () => void }) { + const inspectorOpen = useSimulationViewerStore((s) => s.inspectorOpen); + const toggleInspector = useSimulationViewerStore((s) => s.toggleInspector); + + return ( +
+ + +
+ +
+ {inspectorOpen && } +
+ ); +} + +// ─── Laptop Layout ────────────────────────────────────────────────── +// +// Canvas + controls on the left, inspector sidebar on the right. +// +function LaptopLayout({ onClose }: { onClose: () => void }) { + const inspectorOpen = useSimulationViewerStore((s) => s.inspectorOpen); + const toggleInspector = useSimulationViewerStore((s) => s.toggleInspector); + + return ( +
+ +
+ {/* Main area */} +
+ +
+ +
+
+ {/* Inspector sidebar */} + {inspectorOpen && } +
+
+ ); +} + +// ─── Desktop Layout ───────────────────────────────────────────────── +// +// Same as laptop but with more generous inspector width (handled via +// responsive classes in SimulationInspector). +// +function DesktopLayout({ onClose }: { onClose: () => void }) { + const inspectorOpen = useSimulationViewerStore((s) => s.inspectorOpen); + const toggleInspector = useSimulationViewerStore((s) => s.toggleInspector); + + return ( +
+ +
+
+ +
+ +
+
+ {inspectorOpen && ( + + )} +
+
+ ); +} + +// ─── Wide / Ultrawide Layout ──────────────────────────────────────── +// +// Extra-spacious: wider inspector, larger canvas padding, and room +// for multi-column metric displays. +// +function WideLayout({ onClose }: { onClose: () => void }) { + const inspectorOpen = useSimulationViewerStore((s) => s.inspectorOpen); + const toggleInspector = useSimulationViewerStore((s) => s.toggleInspector); + + return ( +
+ +
+
+ +
+ +
+
+ {inspectorOpen && ( + + )} +
+
+ ); +} + +// ─── Main Exported Component ──────────────────────────────────────── +export interface SimulationViewerProps { + /** Called when the user closes the viewer. */ + onClose: () => void; + className?: string; +} + +/** + * Responsive simulation viewer that adapts its layout to six + * viewport tiers: + * + * | Tier | Width | Layout | + * |------------|------------|-------------------------------------------| + * | Mobile | < 640px | Stacked, sheet inspector, FAB controls | + * | Tablet | 640–1023 | Stacked with bottom inspector strip | + * | Laptop | 1024–1535 | Side inspector, inline controls | + * | Desktop | 1536–1999 | Wider inspector, more space | + * | Wide | 2000+ | Extra-wide inspector, generous canvas | + * | Ultrawide | 2000+ | Same as Wide (uses full available width) | + */ +export function SimulationViewer({ onClose, className }: SimulationViewerProps) { + const isMobile = useIsMobile(); + const isTablet = useMediaQuery(TABLET_QUERY); + const isLaptop = useMediaQuery(LAPTOP_QUERY); + const isDesktop = useMediaQuery(DESKTOP_QUERY); + const isWide = useMediaQuery(WIDE_QUERY); + + const handleClose = useCallback(() => { + onClose(); + }, [onClose]); + + // Determine layout tier (largest matching wins) + let LayoutComponent: React.ComponentType<{ onClose: () => void }>; + if (isMobile) { + LayoutComponent = MobileLayout; + } else if (!isTablet) { + // Below sm but not isMobile — shouldn't happen, but fallback + LayoutComponent = MobileLayout; + } else if (!isLaptop) { + LayoutComponent = TabletLayout; + } else if (!isDesktop) { + LayoutComponent = LaptopLayout; + } else if (!isWide) { + LayoutComponent = DesktopLayout; + } else { + LayoutComponent = WideLayout; + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/simulation/index.ts b/apps/web/src/components/simulation/index.ts new file mode 100644 index 000000000..5c1c7614d --- /dev/null +++ b/apps/web/src/components/simulation/index.ts @@ -0,0 +1,4 @@ +export { SimulationViewer } from "./SimulationViewer"; +export { SimulationCanvas } from "./SimulationCanvas"; +export { SimulationControls } from "./SimulationControls"; +export { SimulationInspector } from "./SimulationInspector"; diff --git a/apps/web/src/mutuallyExclusivePanels.test.ts b/apps/web/src/mutuallyExclusivePanels.test.ts index 44c8994c8..cafaa463e 100644 --- a/apps/web/src/mutuallyExclusivePanels.test.ts +++ b/apps/web/src/mutuallyExclusivePanels.test.ts @@ -146,4 +146,79 @@ describe("resolveExclusivePanelAction", () => { const result = resolveExclusivePanelAction(false, true, false, true, false, false); expect(result).toEqual(["close-code-viewer"]); }); + + // ─── Simulation panel tests ────────────────────────────────────── + it("returns 'close-simulation' when diff transitions open while simulation is open", () => { + const result = resolveExclusivePanelAction( + false, + true, + false, + false, + false, + false, + /* prevSimulationOpen */ true, + /* simulationOpen */ true, + ); + expect(result).toEqual(["close-simulation"]); + }); + + it("closes diff, code viewer, and preview when simulation opens", () => { + const result = resolveExclusivePanelAction( + true, + true, + true, + true, + true, + true, + /* prevSimulationOpen */ false, + /* simulationOpen */ true, + ); + expect(result).toEqual( + expect.arrayContaining(["close-diff", "close-code-viewer", "close-preview"]), + ); + expect(result).toHaveLength(3); + }); + + it("returns empty array when simulation opens but no other panels are open", () => { + const result = resolveExclusivePanelAction( + false, + false, + false, + false, + false, + false, + false, + true, + ); + expect(result).toEqual([]); + }); + + it("returns empty array when only simulation is open (no transition)", () => { + const result = resolveExclusivePanelAction( + false, + false, + false, + false, + false, + false, + true, + true, + ); + expect(result).toEqual([]); + }); + + it("closes simulation when code viewer opens", () => { + const result = resolveExclusivePanelAction(false, false, false, true, false, false, true, true); + expect(result).toEqual(["close-simulation"]); + }); + + it("closes simulation when preview opens", () => { + const result = resolveExclusivePanelAction(false, false, false, false, false, true, true, true); + expect(result).toEqual(["close-simulation"]); + }); + + it("backward-compatible: works without simulation args", () => { + const result = resolveExclusivePanelAction(false, true, true, true, false, false); + expect(result).toEqual(["close-code-viewer"]); + }); }); diff --git a/apps/web/src/mutuallyExclusivePanels.ts b/apps/web/src/mutuallyExclusivePanels.ts index cf316211b..962e825c7 100644 --- a/apps/web/src/mutuallyExclusivePanels.ts +++ b/apps/web/src/mutuallyExclusivePanels.ts @@ -1,7 +1,16 @@ import { useEffect, useRef } from "react"; /** - * Given previous and current open states for three right-side panels, + * Action types for panel mutual exclusivity enforcement. + */ +export type ExclusivePanelAction = + | "close-diff" + | "close-code-viewer" + | "close-preview" + | "close-simulation"; + +/** + * Given previous and current open states for the right-side panels, * returns which panels should be closed to enforce mutual exclusivity, * or an empty array if no action is needed. * @@ -15,31 +24,42 @@ export function resolveExclusivePanelAction( codeViewerOpen: boolean, prevPreviewOpen: boolean, previewOpen: boolean, -): Array<"close-diff" | "close-code-viewer" | "close-preview"> { + prevSimulationOpen: boolean = false, + simulationOpen: boolean = false, +): ExclusivePanelAction[] { const diffJustOpened = diffOpen && !prevDiffOpen; const codeViewerJustOpened = codeViewerOpen && !prevCodeViewerOpen; const previewJustOpened = previewOpen && !prevPreviewOpen; + const simulationJustOpened = simulationOpen && !prevSimulationOpen; - const actions: Array<"close-diff" | "close-code-viewer" | "close-preview"> = []; + const actions: ExclusivePanelAction[] = []; if (diffJustOpened) { if (codeViewerOpen) actions.push("close-code-viewer"); if (previewOpen) actions.push("close-preview"); + if (simulationOpen) actions.push("close-simulation"); } else if (codeViewerJustOpened) { if (diffOpen) actions.push("close-diff"); if (previewOpen) actions.push("close-preview"); + if (simulationOpen) actions.push("close-simulation"); } else if (previewJustOpened) { if (diffOpen) actions.push("close-diff"); if (codeViewerOpen) actions.push("close-code-viewer"); + if (simulationOpen) actions.push("close-simulation"); + } else if (simulationJustOpened) { + if (diffOpen) actions.push("close-diff"); + if (codeViewerOpen) actions.push("close-code-viewer"); + if (previewOpen) actions.push("close-preview"); } return actions; } /** - * Ensures that the diff panel, code viewer, and preview panel are never open - * simultaneously. When one panel transitions from closed → open while another - * is already open, the previously-open panel(s) are closed. + * Ensures that the diff panel, code viewer, preview panel, and simulation + * viewer are never open simultaneously. When one panel transitions from + * closed → open while another is already open, the previously-open panel(s) + * are closed. */ export function useMutuallyExclusivePanels( diffOpen: boolean, @@ -48,18 +68,23 @@ export function useMutuallyExclusivePanels( closeDiff: () => void, closeCodeViewer: () => void, closePreview: () => void, + simulationOpen: boolean = false, + closeSimulation?: () => void, ) { const prevDiffOpen = useRef(diffOpen); const prevCodeViewerOpen = useRef(codeViewerOpen); const prevPreviewOpen = useRef(previewOpen); + const prevSimulationOpen = useRef(simulationOpen); useEffect(() => { const wasDiffOpen = prevDiffOpen.current; const wasCodeViewerOpen = prevCodeViewerOpen.current; const wasPreviewOpen = prevPreviewOpen.current; + const wasSimulationOpen = prevSimulationOpen.current; prevDiffOpen.current = diffOpen; prevCodeViewerOpen.current = codeViewerOpen; prevPreviewOpen.current = previewOpen; + prevSimulationOpen.current = simulationOpen; const actions = resolveExclusivePanelAction( wasDiffOpen, @@ -68,6 +93,8 @@ export function useMutuallyExclusivePanels( codeViewerOpen, wasPreviewOpen, previewOpen, + wasSimulationOpen, + simulationOpen, ); for (const action of actions) { if (action === "close-code-viewer") { @@ -76,7 +103,18 @@ export function useMutuallyExclusivePanels( closeDiff(); } else if (action === "close-preview") { closePreview(); + } else if (action === "close-simulation") { + closeSimulation?.(); } } - }, [diffOpen, codeViewerOpen, previewOpen, closeDiff, closeCodeViewer, closePreview]); + }, [ + diffOpen, + codeViewerOpen, + previewOpen, + simulationOpen, + closeDiff, + closeCodeViewer, + closePreview, + closeSimulation, + ]); } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 07661f87e..710300330 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -26,6 +26,7 @@ import { } from "../diffRouteSearch"; import { useCodeViewerStore } from "../codeViewerStore"; import { usePreviewStateStore } from "../previewStateStore"; +import { useSimulationViewerStore } from "../simulationViewerStore"; import { useMutuallyExclusivePanels } from "../mutuallyExclusivePanels"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useClientMode } from "../hooks/useClientMode"; @@ -35,6 +36,11 @@ import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/component const DiffPanel = lazy(() => import("../components/DiffPanel")); const CodeViewerPanel = lazy(() => import("../components/CodeViewerPanel")); +const SimulationViewerLazy = lazy(() => + import("../components/simulation/SimulationViewer").then((m) => ({ + default: m.SimulationViewer, + })), +); const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; @@ -131,6 +137,22 @@ const LazyCodeViewerPanel = () => { ); }; +const SimulationLoadingFallback = () => { + return ( +
+ +
+ ); +}; + +const LazySimulationViewer = ({ onClose }: { onClose: () => void }) => { + return ( + }> + + + ); +}; + function useShouldAcceptInlineSidebarWidth() { return useCallback(({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { const composerForm = document.querySelector("[data-chat-composer-form='true']"); @@ -288,10 +310,15 @@ function ChatThreadRouteView() { const previewOpen = usePreviewStateStore((state) => state.globalOpen); const setPreviewOpen = usePreviewStateStore((state) => state.setGlobalOpen); + // Simulation viewer state from Zustand store + const simulationOpen = useSimulationViewerStore((state) => state.isOpen); + const closeSimulationStore = useSimulationViewerStore((state) => state.close); + // TanStack Router keeps active route components mounted across param-only navigations // unless remountDeps are configured, so this stays warm across thread switches. const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); const [hasOpenedCodeViewer, setHasOpenedCodeViewer] = useState(codeViewerOpen); + const [hasOpenedSimulation, setHasOpenedSimulation] = useState(simulationOpen); const closeDiff = useCallback(() => { void navigate({ @@ -319,6 +346,10 @@ function ChatThreadRouteView() { setPreviewOpen(false); }, [setPreviewOpen]); + const closeSimulation = useCallback(() => { + closeSimulationStore(); + }, [closeSimulationStore]); + // Enforce mutual exclusivity: only one right-side panel open at a time. useMutuallyExclusivePanels( diffOpen, @@ -327,6 +358,8 @@ function ChatThreadRouteView() { closeDiff, closeCodeViewer, closePreview, + simulationOpen, + closeSimulation, ); useEffect(() => { @@ -341,6 +374,12 @@ function ChatThreadRouteView() { } }, [codeViewerOpen]); + useEffect(() => { + if (simulationOpen) { + setHasOpenedSimulation(true); + } + }, [simulationOpen]); + useEffect(() => { if (!threadsHydrated) { return; @@ -358,6 +397,52 @@ function ChatThreadRouteView() { const shouldRenderDiffContent = diffOpen || hasOpenedDiff; const shouldRenderCodeViewerContent = codeViewerOpen || hasOpenedCodeViewer; + const shouldRenderSimulation = simulationOpen || hasOpenedSimulation; + + // Simulation viewer: on mobile, use a sheet; otherwise render as an + // inline sidebar-like panel that fills the right portion of the screen. + const simulationNode = shouldRenderSimulation ? ( + clientMode === "mobile" ? ( + { + if (!open) closeSimulation(); + }} + > + + + + + ) : ( + { + if (!open) closeSimulation(); + }} + className="w-auto min-h-0 flex-none bg-transparent" + style={{ "--sidebar-width": "clamp(32rem,55vw,52rem)" } as CSSProperties} + > + + + + + + ) + ) : null; if (!shouldUseDiffSheet) { return ( @@ -376,6 +461,7 @@ function ChatThreadRouteView() { onOpenDiff={openDiff} renderDiffContent={shouldRenderDiffContent} /> + {simulationNode} ); } @@ -399,6 +485,7 @@ function ChatThreadRouteView() { {shouldRenderDiffContent ? : null} + {simulationNode} ); } diff --git a/apps/web/src/simulationViewerStore.ts b/apps/web/src/simulationViewerStore.ts new file mode 100644 index 000000000..8015a9c98 --- /dev/null +++ b/apps/web/src/simulationViewerStore.ts @@ -0,0 +1,379 @@ +import { create } from "zustand"; + +// ─── Viewport Presets ──────────────────────────────────────────────── +export type SimulationViewportPreset = + | "mobile" + | "tablet" + | "laptop" + | "desktop" + | "wide" + | "ultrawide"; + +export interface SimulationViewportConfig { + id: SimulationViewportPreset; + label: string; + width: number; + height: number; + /** Columns of grid panels to show in multi-panel layout. */ + panelColumns: 1 | 2 | 3 | 4; + /** Whether to show the controls panel inline vs. collapsed. */ + inlineControls: boolean; + /** Whether the side inspector should be visible by default. */ + showInspector: boolean; +} + +export const SIMULATION_VIEWPORT_PRESETS: readonly SimulationViewportConfig[] = [ + { + id: "mobile", + label: "Mobile", + width: 390, + height: 844, + panelColumns: 1, + inlineControls: false, + showInspector: false, + }, + { + id: "tablet", + label: "Tablet", + width: 768, + height: 1024, + panelColumns: 1, + inlineControls: true, + showInspector: false, + }, + { + id: "laptop", + label: "Laptop", + width: 1366, + height: 768, + panelColumns: 2, + inlineControls: true, + showInspector: true, + }, + { + id: "desktop", + label: "Desktop", + width: 1920, + height: 1080, + panelColumns: 2, + inlineControls: true, + showInspector: true, + }, + { + id: "wide", + label: "Wide", + width: 2560, + height: 1080, + panelColumns: 3, + inlineControls: true, + showInspector: true, + }, + { + id: "ultrawide", + label: "Ultrawide", + width: 3440, + height: 1440, + panelColumns: 4, + inlineControls: true, + showInspector: true, + }, +] as const; + +export function getSimulationViewportPreset( + id: SimulationViewportPreset, +): SimulationViewportConfig | undefined { + return SIMULATION_VIEWPORT_PRESETS.find((p) => p.id === id); +} + +// ─── Playback State ────────────────────────────────────────────────── +export type PlaybackState = "stopped" | "playing" | "paused"; + +// ─── Inspector Tabs ────────────────────────────────────────────────── +export type InspectorTab = "parameters" | "entities" | "timeline" | "metrics"; + +// ─── Simulation Parameter ──────────────────────────────────────────── +export interface SimulationParameter { + id: string; + label: string; + value: number; + min: number; + max: number; + step: number; + unit?: string; +} + +// ─── Entity (for entity inspector) ─────────────────────────────────── +export interface SimulationEntity { + id: string; + label: string; + type: string; + x: number; + y: number; + properties: Record; +} + +// ─── Metric snapshot ───────────────────────────────────────────────── +export interface MetricSnapshot { + tick: number; + timestamp: number; + values: Record; +} + +// ─── Store Interface ───────────────────────────────────────────────── +interface SimulationViewerState { + // Panel open state + isOpen: boolean; + + // Playback + playbackState: PlaybackState; + speed: number; // multiplier (0.25 – 4) + currentTick: number; + maxTick: number; + + // Viewport + viewportPreset: SimulationViewportPreset | null; + zoom: number; // 0.25 – 4 + + // Inspector + inspectorOpen: boolean; + inspectorTab: InspectorTab; + + // Controls drawer (mobile) + controlsDrawerOpen: boolean; + + // Parameters + parameters: SimulationParameter[]; + + // Entities + entities: SimulationEntity[]; + selectedEntityId: string | null; + + // Metrics history + metrics: MetricSnapshot[]; + + // Grid overlay + showGrid: boolean; + showEntityLabels: boolean; + + // ─── Actions ───────────────────────────────────────────────────── + open: () => void; + close: () => void; + + play: () => void; + pause: () => void; + stop: () => void; + setSpeed: (speed: number) => void; + setTick: (tick: number) => void; + setMaxTick: (maxTick: number) => void; + + setViewportPreset: (preset: SimulationViewportPreset | null) => void; + setZoom: (zoom: number) => void; + + toggleInspector: () => void; + setInspectorTab: (tab: InspectorTab) => void; + + toggleControlsDrawer: () => void; + setControlsDrawerOpen: (open: boolean) => void; + + setParameters: (params: SimulationParameter[]) => void; + updateParameter: (id: string, value: number) => void; + + setEntities: (entities: SimulationEntity[]) => void; + selectEntity: (id: string | null) => void; + + pushMetrics: (snapshot: MetricSnapshot) => void; + clearMetrics: () => void; + + toggleGrid: () => void; + toggleEntityLabels: () => void; + + /** Full reset to initial values. */ + reset: () => void; +} + +const STORAGE_KEY = "okcode:simulation-viewer:v1"; + +interface PersistedSimulationState { + inspectorOpen: boolean; + inspectorTab: InspectorTab; + viewportPreset: SimulationViewportPreset | null; + zoom: number; + speed: number; + showGrid: boolean; + showEntityLabels: boolean; +} + +function createDefaultPersistedState(): PersistedSimulationState { + return { + inspectorOpen: true, + inspectorTab: "parameters", + viewportPreset: null, + zoom: 1, + speed: 1, + showGrid: true, + showEntityLabels: true, + }; +} + +function readPersistedState(): PersistedSimulationState { + if (typeof window === "undefined") return createDefaultPersistedState(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return createDefaultPersistedState(); + const parsed = JSON.parse(raw) as Partial; + return { + inspectorOpen: typeof parsed.inspectorOpen === "boolean" ? parsed.inspectorOpen : true, + inspectorTab: + parsed.inspectorTab && + ["parameters", "entities", "timeline", "metrics"].includes(parsed.inspectorTab) + ? parsed.inspectorTab + : "parameters", + viewportPreset: + parsed.viewportPreset && + SIMULATION_VIEWPORT_PRESETS.some((p) => p.id === parsed.viewportPreset) + ? parsed.viewportPreset + : null, + zoom: + typeof parsed.zoom === "number" && Number.isFinite(parsed.zoom) + ? Math.max(0.25, Math.min(4, parsed.zoom)) + : 1, + speed: + typeof parsed.speed === "number" && Number.isFinite(parsed.speed) + ? Math.max(0.25, Math.min(4, parsed.speed)) + : 1, + showGrid: typeof parsed.showGrid === "boolean" ? parsed.showGrid : true, + showEntityLabels: + typeof parsed.showEntityLabels === "boolean" ? parsed.showEntityLabels : true, + }; + } catch { + return createDefaultPersistedState(); + } +} + +function persistState(state: PersistedSimulationState): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Ignore storage errors. + } +} + +function snapshotPersisted(state: SimulationViewerState): PersistedSimulationState { + return { + inspectorOpen: state.inspectorOpen, + inspectorTab: state.inspectorTab, + viewportPreset: state.viewportPreset, + zoom: state.zoom, + speed: state.speed, + showGrid: state.showGrid, + showEntityLabels: state.showEntityLabels, + }; +} + +const persisted = readPersistedState(); + +export const useSimulationViewerStore = create((set, get) => ({ + isOpen: false, + playbackState: "stopped", + speed: persisted.speed, + currentTick: 0, + maxTick: 1000, + viewportPreset: persisted.viewportPreset, + zoom: persisted.zoom, + inspectorOpen: persisted.inspectorOpen, + inspectorTab: persisted.inspectorTab, + controlsDrawerOpen: false, + parameters: [], + entities: [], + selectedEntityId: null, + metrics: [], + showGrid: persisted.showGrid, + showEntityLabels: persisted.showEntityLabels, + + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false, playbackState: "stopped" }), + + play: () => set({ playbackState: "playing" }), + pause: () => set({ playbackState: "paused" }), + stop: () => set({ playbackState: "stopped", currentTick: 0 }), + setSpeed: (speed) => { + const clamped = Math.max(0.25, Math.min(4, speed)); + set({ speed: clamped }); + persistState({ ...snapshotPersisted(get()), speed: clamped }); + }, + setTick: (tick) => set({ currentTick: Math.max(0, Math.min(tick, get().maxTick)) }), + setMaxTick: (maxTick) => set({ maxTick: Math.max(1, maxTick) }), + + setViewportPreset: (preset) => { + set({ viewportPreset: preset }); + persistState({ ...snapshotPersisted(get()), viewportPreset: preset }); + }, + setZoom: (zoom) => { + const clamped = Math.max(0.25, Math.min(4, zoom)); + set({ zoom: clamped }); + persistState({ ...snapshotPersisted(get()), zoom: clamped }); + }, + + toggleInspector: () => { + const next = !get().inspectorOpen; + set({ inspectorOpen: next }); + persistState({ ...snapshotPersisted(get()), inspectorOpen: next }); + }, + setInspectorTab: (tab) => { + set({ inspectorTab: tab }); + persistState({ ...snapshotPersisted(get()), inspectorTab: tab }); + }, + + toggleControlsDrawer: () => set((s) => ({ controlsDrawerOpen: !s.controlsDrawerOpen })), + setControlsDrawerOpen: (open) => set({ controlsDrawerOpen: open }), + + setParameters: (params) => set({ parameters: params }), + updateParameter: (id, value) => + set((state) => ({ + parameters: state.parameters.map((p) => + p.id === id ? { ...p, value: Math.max(p.min, Math.min(p.max, value)) } : p, + ), + })), + + setEntities: (entities) => set({ entities }), + selectEntity: (id) => set({ selectedEntityId: id }), + + pushMetrics: (snapshot) => + set((state) => ({ + metrics: [...state.metrics.slice(-499), snapshot], + })), + clearMetrics: () => set({ metrics: [] }), + + toggleGrid: () => { + const next = !get().showGrid; + set({ showGrid: next }); + persistState({ ...snapshotPersisted(get()), showGrid: next }); + }, + toggleEntityLabels: () => { + const next = !get().showEntityLabels; + set({ showEntityLabels: next }); + persistState({ ...snapshotPersisted(get()), showEntityLabels: next }); + }, + + reset: () => { + const defaults = createDefaultPersistedState(); + set({ + playbackState: "stopped", + currentTick: 0, + speed: defaults.speed, + zoom: defaults.zoom, + viewportPreset: defaults.viewportPreset, + inspectorOpen: defaults.inspectorOpen, + inspectorTab: defaults.inspectorTab, + controlsDrawerOpen: false, + parameters: [], + entities: [], + selectedEntityId: null, + metrics: [], + showGrid: defaults.showGrid, + showEntityLabels: defaults.showEntityLabels, + }); + persistState(defaults); + }, +}));