forrest-mims-library/site/src/components/SimulationEmbed.tsx
Ryan Malloy 9e5a826ca1 Add SpiceBook simulation embeds for interactive circuit exploration
SimulationEmbed React island with click-to-activate iframes, theme sync
via postMessage/MutationObserver, and graceful fallback when SpiceBook
is unavailable. 12 simulations across 6 books (555 timer, op-amp,
semiconductor, formulas, communications, sensor). Books without
simulations render no extra markup. client:visible hydration defers
JS cost until scrolled into view.
2026-02-13 09:13:07 -07:00

251 lines
8.9 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import { Cpu, Play, ExternalLink, AlertCircle } from 'lucide-react';
import type { SimulationRef } from '@/lib/types';
interface SimulationEmbedProps {
simulations: SimulationRef[];
spicebookUrl: string;
}
interface SimCardProps {
sim: SimulationRef;
spicebookUrl: string;
}
function SimCard({ sim, spicebookUrl }: SimCardProps) {
const [active, setActive] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const loadedRef = useRef(false);
const embedUrl = `${spicebookUrl}/embed/${sim.notebookId}`;
// Detect current theme from <html> class
const getTheme = useCallback(() => {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}, []);
// Send theme to iframe via postMessage
const sendTheme = useCallback((theme: string) => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{ type: 'spicebook-theme', theme },
spicebookUrl
);
}
}, [spicebookUrl]);
// Watch for theme changes on <html> element
useEffect(() => {
if (!active) return;
const observer = new MutationObserver(() => {
sendTheme(getTheme());
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, [active, getTheme, sendTheme]);
// Keep loadedRef in sync so the timeout can check current state
// without capturing a stale closure value
useEffect(() => {
loadedRef.current = loaded;
}, [loaded]);
// Set a load timeout — if the iframe doesn't fire onload within 8s,
// assume SpiceBook is unreachable
useEffect(() => {
if (!active) return;
timeoutRef.current = setTimeout(() => {
if (!loadedRef.current) setError(true);
}, 8000);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [active]);
// Stop in-flight network requests when unmounting the iframe
useEffect(() => {
return () => {
if (iframeRef.current) {
iframeRef.current.src = 'about:blank';
}
};
}, []);
const handleLoad = () => {
setLoaded(true);
setError(false);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
sendTheme(getTheme());
};
const handleActivate = () => {
setActive(true);
setError(false);
setLoaded(false);
};
if (!active) {
// Preview card — click to activate
return (
<div className="group relative rounded-xl border border-border bg-card overflow-hidden transition-all hover:shadow-md hover:border-primary/30">
<div className="p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 flex-1">
<h4 className="font-semibold text-card-foreground text-sm leading-tight">
{sim.title}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
{sim.description}
</p>
</div>
{sim.pageRef && (
<span className="shrink-0 text-[10px] font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{sim.pageRef}
</span>
)}
</div>
<button
onClick={handleActivate}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors"
>
<Play size={14} />
Run Simulation
</button>
</div>
{/* Decorative circuit trace along bottom */}
<div className="h-0.5 bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" />
</div>
);
}
// Active state — iframe loaded (or loading/error)
return (
<div className="rounded-xl border border-border bg-card overflow-hidden">
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${loaded ? 'bg-emerald-500' : error ? 'bg-red-400' : 'bg-amber-400 animate-pulse'}`} />
<span className="text-xs font-medium text-card-foreground truncate">
{sim.title}
</span>
{sim.pageRef && (
<span className="text-[10px] font-mono text-muted-foreground">
({sim.pageRef})
</span>
)}
</div>
<div className="flex items-center gap-1">
<a
href={`${spicebookUrl}/notebook/${sim.notebookId}`}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Open in SpiceBook"
>
<ExternalLink size={13} />
</a>
<button
onClick={() => setActive(false)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors text-xs font-medium"
title="Close simulation"
>
&times;
</button>
</div>
</div>
{/* Iframe container with 16:10 aspect ratio */}
<div className="relative" style={{ aspectRatio: '16/10' }}>
{/* Loading state */}
{!loaded && !error && (
<div className="absolute inset-0 flex items-center justify-center sim-loading">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Cpu size={24} className="animate-spin motion-reduce:animate-none" style={{ animationDuration: '3s' }} />
<span className="text-xs">Loading simulation...</span>
</div>
</div>
)}
{/* Error/fallback state */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
<div className="flex flex-col items-center gap-3 text-center px-6">
<AlertCircle size={28} className="text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium text-card-foreground">Simulation coming soon</p>
<p className="text-xs text-muted-foreground">
This notebook is being prepared.{' '}
<a
href={`${spicebookUrl}/notebook/${sim.notebookId}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Try SpiceBook directly
</a>
</p>
</div>
<button
onClick={() => setActive(false)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to preview
</button>
</div>
</div>
)}
{/* SECURITY: allow-same-origin is required so SpiceBook can access its
own storage/cookies. Safe because the iframe src is a different origin.
The parent MUST validate origin on any future postMessage listener.
NOTE: iframe onError does NOT fire for HTTP 4xx/5xx responses —
the 8s timeout is the primary error detection mechanism. A postMessage
handshake with SpiceBook would provide reliable HTTP error detection. */}
<iframe
ref={iframeRef}
src={`${embedUrl}?theme=${getTheme()}`}
className={`w-full h-full border-0 ${loaded ? 'opacity-100' : 'opacity-0'}`}
style={{ transition: 'opacity 0.3s ease' }}
title={`${sim.title} — SpiceBook Simulation`}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
loading="lazy"
onLoad={handleLoad}
onError={() => setError(true)}
/>
</div>
</div>
);
}
export default function SimulationEmbed({ simulations, spicebookUrl }: SimulationEmbedProps) {
return (
<section className="space-y-4">
<div className="flex items-center gap-2.5">
<Cpu size={20} className="text-emerald-600 dark:text-emerald-400" />
<h3 className="text-lg font-semibold text-foreground">Interactive Simulations</h3>
</div>
<p className="text-sm text-muted-foreground">
Run live SPICE simulations of circuits from this notebook. Adjust component values and see waveforms update in real time.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{simulations.map((sim, idx) => (
<SimCard key={`${sim.notebookId}-${idx}`} sim={sim} spicebookUrl={spicebookUrl} />
))}
</div>
</section>
);
}