/**
* 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 && (
)}
{!loading && !error && svgContent && (
)}
{!loading && !error && !svgContent && (
)}
{/* Drag handle — right edge */}
{/* Visible track */}
{/* Grip dots */}
>
)
}