graph-rag / frontend-react /src /views /SimulationRunView.tsx
GitHub Action
Automated sync to Hugging Face
674fb4e
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import type { GraphNode, GraphEdge, DocumentInfo } from '../types/api';
import GraphCanvas, { DEFAULT_OPTIONS } from '../components/GraphCanvas';
import type { GraphOptions, GraphCanvasHandle } from '../components/GraphCanvas';
import {
RefreshCw, Play, Database, Info, Maximize2, Minimize2,
Download, SlidersHorizontal, X, Layers, Tag, Search,
Image, GitBranch, HelpCircle
} from 'lucide-react';
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
const TYPE_COLORS = [
'#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261',
'#6a4c93','#1982c4','#8ac926','#ff595e','#6a994e',
'#bc4749','#a8dadc'
];
const SimulationRunView: React.FC = () => {
const { token, logout } = useAuth();
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [] });
const [loading, setLoading] = useState(false);
const [limit, setLimit] = useState(100);
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [selectedDocId, setSelectedDocId] = useState('');
const [nodeCount, setNodeCount] = useState(0);
const [edgeCount, setEdgeCount] = useState(0);
// UI state
const [isFullscreen, setIsFullscreen] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [highlightNodeIds, setHighlightNodeIds] = useState<Set<string>>(new Set());
// Graph options
const [options, setOptions] = useState<GraphOptions>({ ...DEFAULT_OPTIONS });
// Canvas handle
const canvasRef = useRef<GraphCanvasHandle>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Derived legend
const typeLegend = React.useMemo(() => {
const types = [...new Set(graphData.nodes.map(n => n.type || 'Unknown'))];
return types.map((t, i) => ({ type: t, color: TYPE_COLORS[i % TYPE_COLORS.length] }));
}, [graphData.nodes]);
// Graph stats
const degreeStats = React.useMemo(() => {
if (!graphData.nodes.length) return null;
const degMap = new Map<string, number>();
graphData.nodes.forEach(n => degMap.set(n.id, 0));
graphData.edges.forEach(e => {
degMap.set(e.source, (degMap.get(e.source) || 0) + 1);
degMap.set(e.target, (degMap.get(e.target) || 0) + 1);
});
const degrees = [...degMap.values()];
const avg = degrees.reduce((a, b) => a + b, 0) / degrees.length;
const max = Math.max(...degrees);
const hubs = graphData.nodes.filter(n => (degMap.get(n.id) || 0) >= avg * 2);
return { avg: avg.toFixed(1), max, hubs: hubs.slice(0, 5) };
}, [graphData]);
// ── Data fetching ──────────────────────────────────────────────────────
const fetchDocuments = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/documents`, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) setDocuments((await res.json()).documents);
} catch {}
}, [token]);
const fetchGraph = useCallback(async (docId: string, nodeLimit: number) => {
setLoading(true);
try {
const url = new URL(`${API_BASE}/graph/visualization`);
url.searchParams.append('limit', nodeLimit.toString());
if (docId) url.searchParams.append('document_id', docId);
const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 401) { logout(); return; }
if (res.ok) {
const data = await res.json();
setGraphData(data);
setNodeCount(data.nodes?.length ?? 0);
setEdgeCount(data.edges?.length ?? 0);
setHighlightNodeIds(new Set());
setSearchQuery('');
}
} catch (err) {
console.error('Graph fetch error:', err);
} finally {
setLoading(false);
}
}, [token, logout]);
const handleNodeUpdate = useCallback(async (nodeId: string, newName: string) => {
try {
const res = await fetch(`${API_BASE}/entities/${nodeId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: newName })
});
if (res.status === 401) { logout(); return; }
if (res.ok) {
setGraphData(prev => ({
...prev,
nodes: prev.nodes.map(n => n.id === nodeId ? { ...n, label: newName } : n)
}));
}
} catch {}
}, [token, logout]);
useEffect(() => { fetchDocuments(); }, [fetchDocuments]);
useEffect(() => { fetchGraph(selectedDocId, limit); }, [fetchGraph, selectedDocId, limit]);
// Search handler
const handleSearch = useCallback((q: string) => {
setSearchQuery(q);
if (!q.trim()) {
setHighlightNodeIds(new Set());
return;
}
const lower = q.toLowerCase();
const matched = new Set(
graphData.nodes
.filter(n =>
n.label?.toLowerCase().includes(lower) ||
n.type?.toLowerCase().includes(lower)
)
.map(n => n.id)
);
setHighlightNodeIds(matched);
// Zoom to first match
if (matched.size > 0) {
const firstId = [...matched][0];
canvasRef.current?.highlightNode(firstId);
}
}, [graphData.nodes]);
// ── Fullscreen ─────────────────────────────────────────────────────────
const toggleFullscreen = useCallback(() => {
if (!isFullscreen) {
containerRef.current?.requestFullscreen?.().catch(() => setIsFullscreen(true));
} else {
document.exitFullscreen?.().catch(() => setIsFullscreen(false));
}
setIsFullscreen(f => !f);
}, [isFullscreen]);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
// ── Option helpers ─────────────────────────────────────────────────────
const setOpt = <K extends keyof GraphOptions>(key: K, val: GraphOptions[K]) =>
setOptions(prev => ({ ...prev, [key]: val }));
const selectedDocName = documents.find(d => d.id === selectedDocId)?.filename || '';
return (
<div ref={containerRef} className={`simulation-root${isFullscreen ? ' fullscreen' : ''}`}>
{/* ── Top bar ──────────────────────────────────────────────────────── */}
<div className="sim-topbar">
<div className="sim-title-group">
<h2>GRAPH VISUALIZATION</h2>
<div className="sim-chips">
<span className="s-chip"><Database size={11}/> {nodeCount} nodes</span>
<span className="s-chip">{edgeCount} edges</span>
{degreeStats && <span className="s-chip" title="Average connections per node">avg° {degreeStats.avg}</span>}
{selectedDocId && (
<span className="s-chip active" title={selectedDocName}>
{selectedDocName.length > 22 ? selectedDocName.substring(0,20)+'…' : selectedDocName}
</span>
)}
</div>
</div>
<div className="sim-controls">
{/* Doc filter */}
<div className="ctrl-grp">
<label className="ctrl-lbl">DOCUMENT</label>
<select className="sim-sel" value={selectedDocId} onChange={e => setSelectedDocId(e.target.value)}>
<option value="">🌐 ALL</option>
{documents.map(doc => (
<option key={doc.id} value={doc.id}>📄 {doc.filename.length > 28 ? doc.filename.substring(0,26)+'…' : doc.filename}</option>
))}
</select>
</div>
{/* Limit */}
<div className="ctrl-grp">
<label className="ctrl-lbl">NODES</label>
<select className="sim-sel" value={limit} onChange={e => setLimit(Number(e.target.value))}>
{[50, 100, 200, 500, 1000].map(v => <option key={v} value={v}>{v}</option>)}
</select>
</div>
{/* Action buttons */}
<div className="sim-actions">
<button className="sim-icon-btn" title="Refresh graph" onClick={() => fetchGraph(selectedDocId, limit)} disabled={loading}>
{loading ? <RefreshCw className="spin" size={15}/> : <Play size={15}/>}<span>REFRESH</span>
</button>
<button className="sim-icon-btn" title="Fit to view" onClick={() => canvasRef.current?.fitView()}>
<Layers size={15}/><span>FIT</span>
</button>
{/* Edge labels quick toggle */}
<button
className={`sim-icon-btn${options.showEdgeLabels ? ' active' : ''}`}
title={options.showEdgeLabels ? 'Hide edge labels' : 'Show edge labels'}
onClick={() => setOpt('showEdgeLabels', !options.showEdgeLabels)}
>
<Tag size={15}/><span>LABELS</span>
</button>
{/* Search */}
<button
className={`sim-icon-btn${showSearch ? ' active' : ''}`}
title="Search nodes"
onClick={() => { setShowSearch(s => !s); if (showSearch) { setSearchQuery(''); setHighlightNodeIds(new Set()); } }}
>
<Search size={15}/><span>SEARCH</span>
</button>
{/* Export PNG */}
<button className="sim-icon-btn" title="Export as PNG" onClick={() => canvasRef.current?.exportPNG()}>
<Image size={15}/><span>PNG</span>
</button>
{/* Export SVG */}
<button className="sim-icon-btn" title="Export as SVG" onClick={() => canvasRef.current?.exportSVG()}>
<Download size={15}/><span>SVG</span>
</button>
{/* Options panel */}
<button className={`sim-icon-btn${showPanel ? ' active' : ''}`} title="Advanced options" onClick={() => setShowPanel(p => !p)}>
<SlidersHorizontal size={15}/><span>OPTIONS</span>
</button>
{/* Fullscreen */}
<button className="sim-icon-btn" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} onClick={toggleFullscreen}>
{isFullscreen ? <Minimize2 size={15}/> : <Maximize2 size={15}/>}
<span>{isFullscreen ? 'EXIT' : 'FULL'}</span>
</button>
{/* Help */}
<button className={`sim-icon-btn${showHelp ? ' active' : ''}`} title="Keyboard shortcuts & help" onClick={() => setShowHelp(h => !h)}>
<HelpCircle size={15}/><span>?</span>
</button>
</div>
</div>
</div>
{/* ── Node Search bar ──────────────────────────────────────────────── */}
{showSearch && (
<div className="search-bar-row">
<Search size={14}/>
<input
autoFocus
type="text"
className="sim-search-input"
placeholder="Search by node name or type…"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
{highlightNodeIds.size > 0 && (
<span className="search-result-badge">{highlightNodeIds.size} match{highlightNodeIds.size !== 1 ? 'es' : ''}</span>
)}
<button className="sim-icon-btn" style={{ padding: '0.2rem 0.5rem' }} onClick={() => { setSearchQuery(''); setHighlightNodeIds(new Set()); }}>
<X size={13}/>
</button>
</div>
)}
{/* ── Help modal ───────────────────────────────────────────────────── */}
{showHelp && (
<div className="advanced-panel">
<div className="panel-header">
<span>CONTROLS REFERENCE</span>
<button className="toast-dismiss-btn" onClick={() => setShowHelp(false)}><X size={15}/></button>
</div>
<div className="panel-body" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
<div>
<div className="opt-label">MOUSE / TOUCH</div>
<table className="help-table">
<tbody>
{[
['Single click', 'Zoom to node'],
['Double click', 'Edit node name'],
['Drag node', 'Pin node position'],
['Hover', 'Highlight neighbors'],
['Scroll', 'Zoom in / out'],
['Drag canvas', 'Pan view'],
['Click canvas bg', 'Reset highlight'],
].map(([k, v]) => (
<tr key={k}><td className="help-key">{k}</td><td className="help-val">{v}</td></tr>
))}
</tbody>
</table>
</div>
<div>
<div className="opt-label">TOOLBAR BUTTONS</div>
<table className="help-table">
<tbody>
{[
['REFRESH', 'Reload graph from Neo4j'],
['FIT', 'Reset zoom/pan to center'],
['LABELS', 'Toggle edge relation text'],
['SEARCH', 'Find & highlight nodes'],
['PNG/SVG', 'Export graph image'],
['OPTIONS', 'Physics & display settings'],
['FULL', 'Toggle fullscreen mode'],
].map(([k, v]) => (
<tr key={k}><td className="help-key">{k}</td><td className="help-val">{v}</td></tr>
))}
</tbody>
</table>
</div>
<div>
<div className="opt-label">TIPS</div>
<ul className="help-tips">
<li>Set <strong>NODES</strong> to 50 for better performance on large graphs</li>
<li>Enable <strong>Curved edges</strong> to reduce visual overlap</li>
<li><strong>Node size by degree</strong> makes hubs visually prominent</li>
<li>Use <strong>SEARCH</strong> to find entities by name or type</li>
<li>Increase <strong>Charge Strength</strong> to spread out dense clusters</li>
</ul>
</div>
</div>
</div>
)}
{/* ── Advanced options panel ────────────────────────────────────────── */}
{showPanel && (
<div className="advanced-panel">
<div className="panel-header">
<span>ADVANCED GRAPH OPTIONS</span>
<button className="toast-dismiss-btn" onClick={() => setShowPanel(false)}><X size={15}/></button>
</div>
<div className="panel-body">
{/* Display toggles */}
<div className="opt-section">
<div className="opt-label">DISPLAY</div>
<div className="opt-row">
{([
['colorByType', 'Color nodes by entity type'],
['showLabels', 'Show node name labels'],
['showEdgeLabels', 'Show edge relation labels'],
['showCurvedEdges', 'Curved edges (arc routing)'],
['nodeSizeByDegree', 'Node size by connection count'],
] as [keyof GraphOptions, string][]).map(([key, label]) => (
<label key={key} className="opt-toggle">
<input type="checkbox"
checked={options[key] as boolean}
onChange={e => setOpt(key, e.target.checked)}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{/* Physics sliders */}
<div className="opt-section">
<div className="opt-label">PHYSICS</div>
<div className="opt-sliders">
{([
['nodeRadius', 'Node Radius', 8, 40, 1],
['linkDistance', 'Link Distance', 40, 500, 10],
['chargeStrength', 'Repulsion', -1200, -30, 10],
['centerGravity', 'Center Gravity', 0, 0.5, 0.01],
] as [keyof GraphOptions, string, number, number, number][]).map(([key, label, min, max, step]) => (
<div key={key} className="slider-row">
<label>{label}: <strong>{typeof options[key] === 'number' ? (options[key] as number).toFixed(key === 'centerGravity' ? 2 : 0) : options[key]}</strong></label>
<input type="range" min={min} max={max} step={step}
value={options[key] as number}
onChange={e => setOpt(key, Number(e.target.value))}
/>
</div>
))}
</div>
</div>
<button className="reset-btn" onClick={() => setOptions({ ...DEFAULT_OPTIONS })}>
↺ RESET TO DEFAULTS
</button>
</div>
</div>
)}
{/* ── Canvas + Legend + Stats ───────────────────────────────────────── */}
<div className="canvas-area">
<div className="canvas-wrapper">
{loading && graphData.nodes.length === 0 ? (
<div className="loading-overlay">
<RefreshCw className="spin" size={28}/>
<span>INITIALIZING PHYSICS ENGINE...</span>
</div>
) : graphData.nodes.length === 0 ? (
<div className="loading-overlay empty">
<Database size={40}/>
<span>
{selectedDocId
? 'No entities found for this document.\nTry a different document or re-ingest.'
: 'No entity data in graph.\nIngest documents via the PROCESS tab first.'}
</span>
</div>
) : (
<GraphCanvas
ref={canvasRef}
data={graphData}
onNodeUpdate={handleNodeUpdate}
options={options}
highlightNodeIds={highlightNodeIds}
/>
)}
{loading && graphData.nodes.length > 0 && (
<div className="refresh-pill">
<RefreshCw className="spin" size={13}/> REFRESHING
</div>
)}
</div>
{/* ── Sidebar: Legend + Stats ─────────────────────────────────────── */}
<div className="sidebar-panels">
{/* Type Legend */}
{options.colorByType && typeLegend.length > 0 && (
<div className="legend-panel">
<div className="legend-title">ENTITY TYPES</div>
{typeLegend.map(({ type, color }) => (
<div key={type} className="legend-row">
<span className="legend-dot" style={{ background: color }}/>
<span className="legend-type">{type}</span>
</div>
))}
</div>
)}
{/* Graph stats */}
{degreeStats && (
<div className="stats-sidebar">
<div className="legend-title">NETWORK STATS</div>
<div className="stat-row"><span>Avg Degree</span><strong>{degreeStats.avg}</strong></div>
<div className="stat-row"><span>Max Degree</span><strong>{degreeStats.max}</strong></div>
{degreeStats.hubs.length > 0 && (
<>
<div className="legend-title" style={{ marginTop: '0.6rem' }}>HUB NODES</div>
{degreeStats.hubs.map((n: any) => (
<div
key={n.id}
className="hub-node-row"
title="Click to zoom to this node"
onClick={() => canvasRef.current?.highlightNode(n.id)}
>
<GitBranch size={10}/>
<span>{n.label?.length > 14 ? n.label.substring(0,12)+'…' : n.label}</span>
</div>
))}
</>
)}
</div>
)}
</div>
</div>
{/* ── Hint bar ──────────────────────────────────────────────────────── */}
<div className="sim-hint">
<Info size={11}/>
<span>
<strong>Click</strong> node to zoom · <strong>Double-click</strong> to rename · <strong>Hover</strong> to highlight neighbors ·
<strong> Drag</strong> to pin · <strong>Scroll</strong> to zoom
</span>
</div>
<style>{`
.simulation-root {
height: calc(100vh - 62px);
display: flex;
flex-direction: column;
padding: 0.75rem 1.25rem 0.5rem;
background: var(--bg-color);
overflow: hidden;
gap: 0;
}
.simulation-root.fullscreen {
height: 100vh;
position: fixed;
inset: 0;
z-index: 9999;
background: #fff;
padding: 0.75rem 1.25rem 0.5rem;
}
/* Top bar */
.sim-topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
border-bottom: 2px solid #000;
padding-bottom: 0.6rem;
margin-bottom: 0.5rem;
}
.sim-title-group h2 { margin: 0 0 0.2rem; font-size: 1rem; letter-spacing: 2px; }
.sim-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.s-chip {
display: inline-flex; align-items: center; gap: 3px;
border: 1.5px solid #000; padding: 1px 7px;
font-family: var(--font-mono); font-size: 0.7rem; font-weight: 700;
}
.s-chip.active { background: #000; color: #fff; }
.sim-controls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 0.6rem; }
.ctrl-grp { display: flex; flex-direction: column; gap: 2px; }
.ctrl-lbl {
font-family: var(--font-mono); font-size: 0.62rem;
font-weight: 700; color: #888; letter-spacing: 1px;
}
.sim-sel {
font-family: var(--font-mono); font-size: 0.8rem;
border: 2px solid #000; background: #fff; color: #000;
padding: 0.28rem 0.55rem; cursor: pointer; min-width: 120px;
max-width: 180px;
}
.sim-sel:focus { outline: none; box-shadow: 2px 2px 0 #000; }
.sim-actions { display: flex; flex-wrap: wrap; gap: 5px; align-items: flex-end; }
.sim-icon-btn {
display: inline-flex; align-items: center; gap: 4px;
border: 2px solid #000; background: #fff; color: #000;
padding: 0.28rem 0.65rem; font-family: var(--font-mono);
font-size: 0.7rem; font-weight: 700; cursor: pointer;
letter-spacing: 0.5px; transition: all 0.13s ease;
white-space: nowrap;
}
.sim-icon-btn:hover, .sim-icon-btn.active { background: #000; color: #fff; }
.sim-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
/* Search bar */
.search-bar-row {
display: flex; align-items: center; gap: 0.5rem;
border: 2px solid #000; padding: 0.3rem 0.75rem;
margin-bottom: 0.5rem; background: #fafafa;
}
.sim-search-input {
flex: 1; border: none; background: transparent;
font-family: var(--font-mono); font-size: 0.85rem;
color: #000; outline: none;
}
.search-result-badge {
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
background: #000; color: #fff; padding: 1px 8px;
}
/* Advanced panel */
.advanced-panel {
border: 2px solid #000; background: #fff;
margin-bottom: 0.5rem; box-shadow: 3px 3px 0 #000;
}
.panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 0.4rem 0.9rem;
background: #000; color: #fff;
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 700;
letter-spacing: 1px;
}
.panel-body {
display: flex; flex-wrap: wrap; gap: 1.25rem; padding: 0.9rem 1rem;
}
.opt-section { flex: 1; min-width: 160px; }
.opt-label {
font-family: var(--font-mono); font-size: 0.62rem; font-weight: 700;
color: #888; letter-spacing: 1px; margin-bottom: 0.45rem;
}
.opt-row { display: flex; flex-direction: column; gap: 5px; }
.opt-toggle {
display: flex; align-items: center; gap: 7px;
font-family: var(--font-mono); font-size: 0.77rem; cursor: pointer;
}
.opt-toggle input { cursor: pointer; }
.opt-sliders { display: flex; flex-direction: column; gap: 0.55rem; }
.slider-row {
display: flex; flex-direction: column; gap: 3px;
font-family: var(--font-mono); font-size: 0.75rem;
}
.slider-row input[type=range] { width: 100%; cursor: pointer; }
.reset-btn {
align-self: flex-end;
border: 2px solid #000; background: #fff; color: #000;
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
padding: 0.3rem 0.9rem; cursor: pointer; letter-spacing: 0.5px;
transition: all 0.13s ease;
}
.reset-btn:hover { background: #000; color: #fff; }
/* Help table */
.help-table { width: 100%; border-collapse: collapse; }
.help-table td { vertical-align: top; padding: 2px 0; font-family: var(--font-mono); font-size: 0.74rem; }
.help-key { color: #000; font-weight: 700; white-space: nowrap; padding-right: 0.75rem; }
.help-val { color: #555; }
.help-tips { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
.help-tips li { font-family: var(--font-mono); font-size: 0.74rem; color: #555; line-height: 1.4; }
.help-tips li::before { content: '→ '; color: #000; font-weight: 700; }
.help-tips strong { color: #000; }
/* Canvas area */
.canvas-area {
flex: 1; display: flex; gap: 0.6rem; min-height: 0; overflow: hidden;
}
.canvas-wrapper {
flex: 1; border: 2px solid #000;
background: #fafafa; position: relative; overflow: hidden; min-height: 0;
}
.loading-overlay {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; background: rgba(255,255,255,0.92);
font-family: var(--font-mono); font-size: 0.88rem;
letter-spacing: 1.2px; color: #555;
white-space: pre-line; text-align: center; padding: 2rem; z-index: 10;
}
.loading-overlay.empty { color: #bbb; }
.refresh-pill {
position: absolute; top: 8px; right: 8px;
display: flex; align-items: center; gap: 5px;
background: rgba(0,0,0,0.85); color: #fff;
font-family: var(--font-mono); font-size: 0.7rem;
padding: 2px 9px; z-index: 20;
}
/* Sidebar panels */
.sidebar-panels {
display: flex; flex-direction: column; gap: 0.5rem;
width: 150px; flex-shrink: 0; overflow-y: auto;
}
.legend-panel, .stats-sidebar {
border: 2px solid #000; background: #fff; padding: 0.65rem 0.75rem; flex-shrink: 0;
}
.legend-title {
font-family: var(--font-mono); font-size: 0.6rem; font-weight: 700;
color: #888; letter-spacing: 1px; margin-bottom: 0.5rem; text-transform: uppercase;
}
.legend-row { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
.legend-dot {
width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.15);
}
.legend-type {
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stat-row {
display: flex; justify-content: space-between; align-items: center;
font-family: var(--font-mono); font-size: 0.72rem;
color: #555; margin-bottom: 3px;
}
.stat-row strong { color: #000; font-weight: 700; }
.hub-node-row {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-mono); font-size: 0.69rem;
color: #333; margin-bottom: 3px; cursor: pointer;
padding: 2px 4px; transition: background 0.12s;
}
.hub-node-row:hover { background: #f0f0f0; }
/* Hint bar */
.sim-hint {
display: flex; align-items: center; gap: 5px;
font-size: 0.69rem; color: #999; font-family: var(--font-mono);
padding: 0.25rem 0; border-top: 1px dashed #ccc; margin-top: 0.25rem;
flex-shrink: 0;
}
.sim-hint strong { color: #555; }
.spin { animation: spin 1.2s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
`}</style>
</div>
);
};
export default SimulationRunView;