Spaces:
Running
Running
vaibuzz
feat: production-ready deployment — Dockerfile, schema fixes, PCT_DIFF logic, abs() resolver
1294b5d | /** | |
| * 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 ( | |
| <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> | |
| <circle cx="5" cy="12" r="2" /> | |
| <circle cx="19" cy="5" r="2" /> | |
| <circle cx="19" cy="19" r="2" /> | |
| <line x1="7" y1="11" x2="17" y2="6" /> | |
| <line x1="7" y1="13" x2="17" y2="18" /> | |
| </svg> | |
| ) | |
| } | |
| // ── Spinner ────────────────────────────────────────────────────────────────── | |
| function Spinner() { | |
| return ( | |
| <div className="flex flex-col items-center justify-center gap-4 py-20"> | |
| <div className="relative w-12 h-12"> | |
| <span className="absolute inset-0 rounded-full border-2 border-violet-500/20" /> | |
| <span className="absolute inset-0 rounded-full border-2 border-t-violet-400 border-r-transparent border-b-transparent border-l-transparent animate-spin" /> | |
| </div> | |
| <p className="text-sm text-slate-400 font-medium">Generating graph…</p> | |
| <p className="text-xs text-slate-600">Calling backend + rendering SVG</p> | |
| </div> | |
| ) | |
| } | |
| // ── 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 && ( | |
| <div | |
| className="fixed inset-0 bg-black/30 backdrop-blur-sm z-20 xl:hidden" | |
| onClick={onClose} | |
| /> | |
| )} | |
| {/* Drawer */} | |
| <div | |
| className={`fixed top-0 left-0 h-full bg-slate-900 border-r border-slate-700/50 z-30 flex flex-col shadow-2xl transition-transform duration-300 ${ | |
| open ? 'translate-x-0 animate-slide-in-left' : '-translate-x-full' | |
| }`} | |
| style={{ width: panelWidth }} | |
| > | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/50 bg-slate-900 shrink-0"> | |
| <div className="flex items-center gap-2.5"> | |
| <div className="w-8 h-8 rounded-lg bg-violet-600/20 border border-violet-500/30 flex items-center justify-center"> | |
| <GraphIcon className="w-4 h-4 text-violet-400" /> | |
| </div> | |
| <div> | |
| <h2 className="text-sm font-bold text-white tracking-wide">Rule Graph</h2> | |
| <p className="text-xs text-slate-500 mt-0.5">Decision tree · {rules?.length ?? 0} rules</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {/* Width indicator */} | |
| <span className="text-xs text-slate-600 font-mono hidden sm:inline"> | |
| {panelWidth}px | |
| </span> | |
| <button | |
| onClick={onClose} | |
| className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800 transition-all" | |
| > | |
| ✕ | |
| </button> | |
| </div> | |
| </div> | |
| {/* Body — scrollable */} | |
| <div className="flex-1 overflow-y-auto overflow-x-auto p-4"> | |
| {loading && <Spinner />} | |
| {!loading && error && ( | |
| <div className="mt-4 text-sm bg-red-950/30 border border-red-500/20 rounded-xl p-4"> | |
| <p className="font-semibold text-red-400 mb-1">⚠ Graph error</p> | |
| <p className="text-xs text-red-300/80">{error}</p> | |
| </div> | |
| )} | |
| {!loading && !error && svgContent && ( | |
| <div | |
| className="animate-fade-in" | |
| style={{ | |
| /* Scale the SVG to fill the panel width while keeping readability */ | |
| width: '100%', | |
| overflowX: 'auto', | |
| }} | |
| dangerouslySetInnerHTML={{ __html: svgContent }} | |
| /> | |
| )} | |
| {!loading && !error && !svgContent && ( | |
| <div className="flex flex-col items-center justify-center gap-3 py-20 text-slate-600"> | |
| <GraphIcon className="w-10 h-10 opacity-30" /> | |
| <p className="text-sm">Graph will appear here</p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Drag handle — right edge */} | |
| <div | |
| onMouseDown={onDragStart} | |
| className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize group z-40" | |
| title="Drag to resize" | |
| > | |
| {/* Visible track */} | |
| <div className="absolute inset-y-0 right-0 w-1 bg-slate-700/50 group-hover:bg-violet-500/60 transition-colors duration-150" /> | |
| {/* Grip dots */} | |
| <div className="absolute top-1/2 -translate-y-1/2 right-0 flex flex-col gap-1 pr-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| {[0,1,2].map(i => ( | |
| <div key={i} className="w-1 h-1 rounded-full bg-violet-400" /> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </> | |
| ) | |
| } | |