/** * RuleGraphPanel — Left-side drawer showing the Visual Rule Graph. * * Features: * - Mirrors SidePanel.jsx: fixed top-0 left-0, slides from left * - Resizable via drag handle on right edge (min 320px, max 50vw) * - Mermaid 10.6.1 from CDN, dark theme, larger legible text * - Debounced 500ms re-render on rules change * - Graceful error display, spinner while loading */ import React, { useState, useEffect, useRef, useCallback } from 'react' import { generateRuleGraph } from '../api/client' // ── Mermaid CDN ────────────────────────────────────────────────────────────── const MERMAID_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.6.1/mermaid.min.js' let _mermaidPromise = null function loadMermaid() { if (_mermaidPromise) return _mermaidPromise _mermaidPromise = new Promise((resolve, reject) => { if (window.mermaid) { resolve(window.mermaid); return } const s = document.createElement('script') s.src = MERMAID_CDN s.onload = () => { window.mermaid.initialize({ startOnLoad: false, theme: 'dark', themeCSS: ` .edgeLabel { background-color: #cbd5e1 !important; color: #0f172a !important; font-size: 14px !important; font-weight: 700 !important; padding: 4px 8px !important; border-radius: 4px !important; } .node rect, .node polygon, .node circle, .node ellipse { rx: 6px !important; ry: 6px !important; } .node { cursor: pointer; } `, flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis', nodeSpacing: 50, rankSpacing: 60, }, themeVariables: { fontSize: '16px', fontFamily: 'Inter, system-ui, sans-serif', primaryColor: '#3730a3', primaryTextColor: '#f8fafc', primaryBorderColor: '#6366f1', lineColor: '#94a3b8', secondaryColor: '#1e1b4b', tertiaryColor: '#0f172a', edgeLabelBackground: '#cbd5e1', clusterBkg: '#1e293b', }, }) resolve(window.mermaid) } s.onerror = () => reject(new Error('Mermaid CDN failed to load')) document.head.appendChild(s) }) return _mermaidPromise } // ── Graph icon ─────────────────────────────────────────────────────────────── export function GraphIcon({ className = 'w-4 h-4' }) { return ( ) } // ── Spinner ────────────────────────────────────────────────────────────────── function Spinner() { return (

Generating graph…

Calling backend + rendering SVG

) } // ── Main component ─────────────────────────────────────────────────────────── const MIN_WIDTH = 320 const DEFAULT_WIDTH = 448 // 28rem — same as before export default function RuleGraphPanel({ open, onClose, rules, onWidthChange }) { const [svgContent, setSvgContent] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) const debounceRef = useRef(null) const renderIdRef = useRef(0) const isDragging = useRef(false) const dragStartX = useRef(0) const dragStartW = useRef(DEFAULT_WIDTH) // ── Graph render ───────────────────────────────────────────────────────── const renderGraph = useCallback(async (snapshot) => { if (!snapshot || snapshot.length === 0) { setSvgContent(''); setError('No rules to graph.'); return } setLoading(true); setError('') const id = ++renderIdRef.current try { const res = await generateRuleGraph(snapshot) const mermaidStr = res.data.mermaid if (id !== renderIdRef.current) return const mermaid = await loadMermaid() if (id !== renderIdRef.current) return const uid = `rg-${Date.now()}-${id}` const { svg } = await mermaid.render(uid, mermaidStr) if (id !== renderIdRef.current) return setSvgContent(svg) } catch (e) { if (id !== renderIdRef.current) return setError(e?.response?.data?.detail || e?.message || 'Render failed.') setSvgContent('') } finally { if (id === renderIdRef.current) setLoading(false) } }, []) // Debounce re-render when rules change while panel is open useEffect(() => { if (!open) return if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => renderGraph(rules), 500) return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } }, [open, rules, renderGraph]) // ── Resize drag handle ─────────────────────────────────────────────────── const maxWidth = typeof window !== 'undefined' ? Math.floor(window.innerWidth * 0.5) : 700 function onDragStart(e) { isDragging.current = true dragStartX.current = e.clientX dragStartW.current = panelWidth document.body.style.cursor = 'ew-resize' document.body.style.userSelect = 'none' } useEffect(() => { function onMouseMove(e) { if (!isDragging.current) return const delta = e.clientX - dragStartX.current const newW = Math.min(maxWidth, Math.max(MIN_WIDTH, dragStartW.current + delta)) setPanelWidth(newW) if (onWidthChange) onWidthChange(newW) } function onMouseUp() { if (!isDragging.current) return isDragging.current = false document.body.style.cursor = '' document.body.style.userSelect = '' } window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) return () => { window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) } }, [maxWidth]) return ( <> {/* Backdrop on narrow screens */} {open && (
)} {/* Drawer */}
{/* Header */}

Rule Graph

Decision tree · {rules?.length ?? 0} rules

{/* Width indicator */} {panelWidth}px
{/* Body — scrollable */}
{loading && } {!loading && error && (

⚠ Graph error

{error}

)} {!loading && !error && svgContent && (
)} {!loading && !error && !svgContent && (

Graph will appear here

)}
{/* Drag handle — right edge */}
{/* Visible track */}
{/* Grip dots */}
{[0,1,2].map(i => (
))}
) }