cc-rag / frontend /src /components /GraphView.tsx
matsuap's picture
fix(frontend): declare react-cytoscapejs module, add explicit any types
3b2d223
Raw
History Blame Contribute Delete
7.05 kB
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',
})),
] : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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>
)
}