| import { useState, useEffect, useCallback } from 'react' |
| import CytoscapeComponent from 'react-cytoscapejs' |
| import type { ElementDefinition } from 'cytoscape' |
| import { api } from '../api' |
| import type { GraphData, GraphEdge } from '../types' |
|
|
| interface Props { documentId: string } |
|
|
| const THEME_COLORS: Record<string, string> = { |
| supply_chain: '#6366f1', |
| finance: '#10b981', |
| operations: '#f59e0b', |
| general: '#8892a4', |
| } |
|
|
| function nodeColor(theme: string) { |
| return THEME_COLORS[theme] || '#6366f1' |
| } |
|
|
| export function GraphView({ documentId }: Props) { |
| const [data, setData] = useState<GraphData | null>(null) |
| const [loading, setLoading] = useState(true) |
| const [error, setError] = useState<string | null>(null) |
| const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null) |
| const [selectedNode, setSelectedNode] = useState<string | null>(null) |
| const [themeFilter, setThemeFilter] = useState('') |
| const [cyRef, setCyRef] = useState<any>(null) |
|
|
| const load = useCallback(() => { |
| setLoading(true) |
| api.getGraph(documentId) |
| .then(setData) |
| .catch(e => setError(String(e.message))) |
| .finally(() => setLoading(false)) |
| }, [documentId]) |
|
|
| useEffect(() => { load() }, [load]) |
|
|
| const themes = data ? [...new Set(data.edges.map(e => e.theme))].filter(Boolean) : [] |
|
|
| const elements: ElementDefinition[] = data ? [ |
| ...data.nodes |
| .filter(n => !themeFilter || data.edges.some(e => (e.source === n.id || e.target === n.id) && e.theme === themeFilter)) |
| .map(n => ({ |
| data: { id: n.id, label: n.label, theme: n.theme }, |
| classes: 'event-node', |
| })), |
| ...data.edges |
| .filter(e => !themeFilter || e.theme === themeFilter) |
| .map(e => ({ |
| data: { |
| id: e.id, source: e.source, target: e.target, |
| label: `${e.label} (${e.confidence.toFixed(2)})`, |
| confidence: e.confidence, evidence: e.evidence, theme: e.theme, |
| edgeData: e, |
| }, |
| classes: 'causal-edge', |
| })), |
| ] : [] |
|
|
| |
| const stylesheet: any[] = [ |
| { |
| selector: 'node', |
| style: { |
| label: 'data(label)', |
| 'background-color': (ele: any) => nodeColor(ele.data('theme') || 'general'), |
| color: '#fff', |
| 'font-size': 11, |
| 'text-valign': 'center', |
| 'text-halign': 'center', |
| 'text-wrap': 'wrap', |
| 'text-max-width': 100, |
| width: 'label', |
| height: 'label', |
| padding: 10, |
| shape: 'round-rectangle', |
| 'border-width': 2, |
| 'border-color': 'transparent', |
| }, |
| }, |
| { |
| selector: 'node:selected', |
| style: { 'border-color': '#fff', 'border-width': 2 }, |
| }, |
| { |
| selector: 'edge', |
| style: { |
| 'line-color': '#4f5880', |
| 'target-arrow-color': '#4f5880', |
| 'target-arrow-shape': 'triangle', |
| 'curve-style': 'bezier', |
| label: 'data(label)', |
| 'font-size': 9, |
| color: '#8892a4', |
| 'text-rotation': 'autorotate', |
| width: (ele: any) => Math.max(1, ele.data('confidence') * 3), |
| }, |
| }, |
| { |
| selector: 'edge:selected', |
| style: { 'line-color': '#6366f1', 'target-arrow-color': '#6366f1' }, |
| }, |
| ] |
|
|
| if (loading) return <div className="spinner" /> |
| if (error) return <div className="error-msg">{error}</div> |
| if (!data || data.nodes.length === 0) return ( |
| <div className="empty-state"><p>No graph data. Build graph first.</p></div> |
| ) |
|
|
| return ( |
| <div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12 }}> |
| <div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}> |
| {themes.length > 0 && ( |
| <div> |
| <select value={themeFilter} onChange={e => setThemeFilter(e.target.value)} style={{ width: 160 }}> |
| <option value="">All themes</option> |
| {themes.map(t => <option key={t} value={t}>{t}</option>)} |
| </select> |
| </div> |
| )} |
| <button className="btn-secondary" onClick={load} style={{ fontSize: 12 }}>Reload</button> |
| <button className="btn-secondary" onClick={() => cyRef?.fit()} style={{ fontSize: 12 }}>Fit</button> |
| <span style={{ fontSize: 12, color: 'var(--muted)' }}> |
| {data.nodes.length} nodes · {data.edges.length} edges |
| </span> |
| </div> |
| |
| <div style={{ display: 'flex', gap: 12, flex: 1, minHeight: 0 }}> |
| <div style={{ |
| flex: 1, background: 'var(--bg2)', border: '1px solid var(--border)', |
| borderRadius: 'var(--radius)', overflow: 'hidden', |
| }}> |
| <CytoscapeComponent |
| elements={elements} |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| stylesheet={stylesheet as any} |
| layout={{ name: 'cose', idealEdgeLength: 120, animate: false }} |
| style={{ width: '100%', height: '100%', minHeight: 400 }} |
| cy={(cy: any) => { |
| setCyRef(cy) |
| cy.on('tap', 'edge', (evt: any) => { |
| setSelectedEdge(evt.target.data('edgeData')) |
| setSelectedNode(null) |
| }) |
| cy.on('tap', 'node', (evt: any) => { |
| setSelectedNode(evt.target.data('label')) |
| setSelectedEdge(null) |
| }) |
| cy.on('tap', (evt: any) => { |
| if (evt.target === cy) { setSelectedEdge(null); setSelectedNode(null) } |
| }) |
| }} |
| /> |
| </div> |
| |
| {(selectedEdge || selectedNode) && ( |
| <div className="card" style={{ width: 260, flexShrink: 0, fontSize: 13, overflowY: 'auto' }}> |
| {selectedNode && ( |
| <> |
| <div className="label">Node</div> |
| <div style={{ fontWeight: 600, marginBottom: 8 }}>{selectedNode}</div> |
| </> |
| )} |
| {selectedEdge && ( |
| <> |
| <div className="label">Edge</div> |
| <div style={{ marginBottom: 8 }}> |
| <span style={{ color: '#f87171' }}>{selectedEdge.source}</span> |
| <span style={{ color: 'var(--muted)', margin: '0 6px' }}>→</span> |
| <span style={{ color: '#60a5fa' }}>{selectedEdge.target}</span> |
| </div> |
| <div style={{ marginBottom: 4 }}> |
| <span className={`badge ${selectedEdge.label}`}>{selectedEdge.label}</span> |
| </div> |
| <div style={{ color: 'var(--muted)', marginBottom: 8 }}> |
| Confidence: <strong>{selectedEdge.confidence.toFixed(2)}</strong> |
| </div> |
| {selectedEdge.evidence && ( |
| <> |
| <div className="label">Evidence</div> |
| <div style={{ color: 'var(--muted)', fontStyle: 'italic' }}>"{selectedEdge.evidence}"</div> |
| </> |
| )} |
| </> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| ) |
| } |
|
|