Kraft102's picture
fix: sql.js Docker/Alpine compatibility layer for PatternMemory and FailureMemory
5a81b95
/**
* ╔═══════════════════════════════════════════════════════════════════════════╗
* β•‘ MERMAID DIAGRAM COMPONENT β•‘
* ║═══════════════════════════════════════════════════════════════════════════║
* β•‘ Renders Mermaid diagrams from DSL syntax β•‘
* β•‘ Part of the Visual Cortex Layer β•‘
* β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Download, Copy, ZoomIn, ZoomOut, Maximize2, RefreshCw } from 'lucide-react';
export interface MermaidDiagramProps {
/** Mermaid DSL syntax */
code: string;
/** Diagram title (optional) */
title?: string;
/** Theme: dark, default, forest, neutral */
theme?: 'dark' | 'default' | 'forest' | 'neutral';
/** Background color */
backgroundColor?: string;
/** Enable zoom controls */
zoomable?: boolean;
/** Custom class */
className?: string;
/** Callback when diagram is rendered */
onRender?: (svg: string) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
export function MermaidDiagram({
code,
title,
theme = 'dark',
backgroundColor = 'transparent',
zoomable = true,
className,
onRender,
onError,
}: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [mermaidLoaded, setMermaidLoaded] = useState(false);
// Dynamically load mermaid
useEffect(() => {
const loadMermaid = async () => {
try {
const mermaid = (await import('mermaid')).default;
mermaid.initialize({
startOnLoad: false,
theme: theme,
securityLevel: 'loose',
fontFamily: 'JetBrains Mono, monospace',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis',
},
sequence: {
useMaxWidth: true,
diagramMarginX: 50,
diagramMarginY: 10,
},
themeVariables: theme === 'dark' ? {
primaryColor: '#6366f1',
primaryTextColor: '#e2e8f0',
primaryBorderColor: '#4f46e5',
lineColor: '#64748b',
secondaryColor: '#1e293b',
tertiaryColor: '#0f172a',
background: '#0f172a',
mainBkg: '#1e293b',
nodeBorder: '#4f46e5',
clusterBkg: '#1e293b',
clusterBorder: '#334155',
titleColor: '#f8fafc',
edgeLabelBackground: '#1e293b',
} : undefined,
});
setMermaidLoaded(true);
} catch (err) {
console.error('Failed to load mermaid:', err);
setError('Failed to load diagram library');
setIsLoading(false);
}
};
loadMermaid();
}, [theme]);
// Render diagram when code changes
useEffect(() => {
if (!mermaidLoaded || !code) return;
const renderDiagram = async () => {
setIsLoading(true);
setError(null);
try {
const mermaid = (await import('mermaid')).default;
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Validate and render
const { svg: renderedSvg } = await mermaid.render(id, code);
setSvg(renderedSvg);
onRender?.(renderedSvg);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to render diagram';
setError(errorMessage);
onError?.(err instanceof Error ? err : new Error(errorMessage));
} finally {
setIsLoading(false);
}
};
renderDiagram();
}, [code, mermaidLoaded, onRender, onError]);
// Copy SVG to clipboard
const handleCopy = useCallback(async () => {
if (svg) {
await navigator.clipboard.writeText(svg);
}
}, [svg]);
// Download as SVG
const handleDownload = useCallback(() => {
if (!svg) return;
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'diagram'}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [svg, title]);
// Zoom controls
const handleZoomIn = () => setZoom(z => Math.min(z + 0.25, 3));
const handleZoomOut = () => setZoom(z => Math.max(z - 0.25, 0.25));
const handleReset = () => setZoom(1);
return (
<div className={cn(
'mermaid-diagram rounded-lg border border-border/50 overflow-hidden',
className
)}>
{/* Header */}
{(title || zoomable) && (
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border/30">
{title && (
<span className="text-sm font-medium text-foreground/80">{title}</span>
)}
<div className="flex items-center gap-1">
{zoomable && (
<>
<button
onClick={handleZoomOut}
className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title="Zoom out"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="text-xs text-muted-foreground min-w-[3rem] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title="Zoom in"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleReset}
className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title="Reset zoom"
>
<Maximize2 className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-border/50 mx-1" />
</>
)}
<button
onClick={handleCopy}
className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title="Copy SVG"
disabled={!svg}
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="p-1.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title="Download SVG"
disabled={!svg}
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Diagram Container */}
<div
ref={containerRef}
className="relative overflow-auto p-4"
style={{ backgroundColor }}
>
{isLoading && (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-6 h-6 animate-spin text-primary" />
<span className="ml-2 text-sm text-muted-foreground">Rendering diagram...</span>
</div>
)}
{error && (
<div className="p-4 rounded bg-red-500/10 border border-red-500/30 text-red-400">
<p className="text-sm font-medium">Diagram Error</p>
<p className="text-xs mt-1 opacity-80">{error}</p>
<pre className="mt-2 text-xs bg-black/20 p-2 rounded overflow-auto max-h-32">
{code}
</pre>
</div>
)}
{!isLoading && !error && svg && (
<div
className="mermaid-svg-container transition-transform duration-200 ease-out"
style={{
transform: `scale(${zoom})`,
transformOrigin: 'top left',
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
</div>
</div>
);
}
export default MermaidDiagram;