| 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); |
|
|
| |
| 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()); |
|
|
| |
| const [options, setOptions] = useState<GraphOptions>({ ...DEFAULT_OPTIONS }); |
|
|
| |
| const canvasRef = useRef<GraphCanvasHandle>(null); |
| const containerRef = useRef<HTMLDivElement>(null); |
|
|
| |
| 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]); |
|
|
| |
| 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]); |
|
|
| |
| 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]); |
|
|
| |
| 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); |
| |
| if (matched.size > 0) { |
| const firstId = [...matched][0]; |
| canvasRef.current?.highlightNode(firstId); |
| } |
| }, [graphData.nodes]); |
|
|
| |
| 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); |
| }, []); |
|
|
| |
| 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> |
|
|
| {} |
| <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; |
| } |
| |
| |
| .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-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 { |
| 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 { 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 { |
| 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 { |
| 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; } |
| |
| |
| .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; |
|
|