import React, { useEffect, useRef, useState } from 'react'; import cytoscape from 'cytoscape'; import { X, Maximize2, Layers } from 'lucide-react'; const demoData = { nodes: [ { data: { id: '740506', label: '740506', type: 'SalesOrder', amount: '17108.25', currency: 'INR', status: 'C' }}, { data: { id: '740507', label: '740507', type: 'SalesOrder', amount: '19021.27', currency: 'INR', status: 'C' }}, { data: { id: '80737721', label: '80737721', type: 'Delivery', status: 'Completed' }}, { data: { id: '80737722', label: '80737722', type: 'Delivery', status: 'Completed' }}, { data: { id: '91150187', label: '91150187', type: 'BillingDoc', netValue: '17108.25' }}, { data: { id: '91150188', label: '91150188', type: 'BillingDoc', netValue: '19021.27' }}, { data: { id: '9400635958', label: '9400635958', type: 'JournalEntry', gl_account: '15500020' }}, { data: { id: '310000108', label: 'Cardenas Parker', type: 'Customer', region: 'Millerborough' }}, { data: { id: 'P1', label: 'TXN-9A82B', type: 'Payment', amount: '17108.25', status: 'Cleared' }} ], edges: [ { data: { source: '740506', target: '80737721' } }, { data: { source: '740507', target: '80737722' } }, { data: { source: '80737721', target: '91150187' } }, { data: { source: '80737722', target: '91150188' } }, { data: { source: '91150187', target: '9400635958' } }, { data: { source: '9400635958', target: 'P1' } }, { data: { source: '310000108', target: '740506' } }, { data: { source: '310000108', target: '740507' } } ] }; // Backend API URL (Dynamic for Vercel/Render) const API_BASE = '/api'; const GraphPanel = ({ setCyInstance, onExplainNode }) => { const containerRef = useRef(null); const cyRef = useRef(null); const [counts, setCounts] = useState({ nodes: 0, edges: 0 }); const [popup, setPopup] = useState(null); const [filter, setFilter] = useState('All'); const [labelsVisible, setLabelsVisible] = useState(true); useEffect(() => { if (!containerRef.current) return; fetch(API_BASE + '/graph') .then(res => res.json()) .then(data => { let nodeIds = new Set(data.nodes.map(n => String(n.data.id))); let validEdges = data.edges.filter(e => nodeIds.has(String(e.data.source)) && nodeIds.has(String(e.data.target))); let elements = [...data.nodes, ...validEdges]; elements.forEach(el => { if (el.data.source) return; const t = el.data.type; if (t === 'SalesOrder') el.classes = 'hub'; else if (t === 'Customer') el.classes = 'customer'; else if (t === 'Delivery') el.classes = 'connected'; else if (t === 'BillingDoc') el.classes = 'leaf billing'; else if (t === 'JournalEntry') el.classes = 'leaf journal'; else if (t === 'Payment') el.classes = 'leaf payment'; else if (t === 'SOItem') el.classes = 'soitem'; else if (t === 'Product') el.classes = 'product'; }); const cy = cytoscape({ container: containerRef.current, elements: elements, style: [ { selector: 'node', style: { 'label': 'data(label)', 'font-size': 11, 'text-valign': 'bottom', 'text-margin-y': 6, 'color': '#555', 'font-family': 'sans-serif' } }, { selector: '.hub', style: { width: 22, height: 22, 'background-color': '#3b82f6' } }, { selector: '.customer', style: { width: 18, height: 18, 'background-color': '#22c55e' } }, { selector: '.connected', style: { width: 14, height: 14, 'background-color': '#93c5e8' } }, { selector: '.leaf.billing', style: { width: 9, height: 9, 'background-color': '#f87171' } }, { selector: '.leaf.journal', style: { width: 9, height: 9, 'background-color': '#8b5cf6' } }, { selector: '.leaf.payment', style: { width: 8, height: 8, 'background-color': '#f59e0b' } }, { selector: '.soitem', style: { width: 10, height: 10, 'background-color': '#64748b' } }, { selector: '.product', style: { width: 12, height: 12, 'background-color': '#ec4899', 'shape': 'square' } }, { selector: 'edge', style: { width: 1.5, 'line-color': 'rgba(147,197,232,0.4)', 'curve-style': 'bezier', 'target-arrow-color': 'rgba(147,197,232,0.4)', 'target-arrow-shape': 'triangle', 'arrow-scale': 0.8 } }, { selector: '.highlighted', style: { 'border-width': 3, 'border-color': '#1a1a1a', 'background-color': '#f59e0b', width: 30, height: 30, 'z-index': 100 } }, { selector: '.dimmed', style: { 'opacity': 0.15 } }, { selector: '.hide-label', style: { 'text-opacity': 0 } } ], layout: { name: 'cose', nodeRepulsion: 14000, idealEdgeLength: 120, animate: true, animationDuration: 1500 } }); cyRef.current = cy; setCyInstance(cy); setCounts({ nodes: cy.nodes().length, edges: cy.edges().length }); cy.on('tap', 'node', async (evt) => { const node = evt.target; const pos = evt.renderedPosition; cy.elements().addClass('dimmed').removeClass('highlighted'); node.removeClass('dimmed').addClass('highlighted'); node.connectedEdges().removeClass('dimmed').addClass('highlighted'); node.connectedEdges().connectedNodes().removeClass('dimmed').addClass('highlighted'); let x = pos.x + 20; let y = pos.y - 40; if (x + 270 > cy.width()) x = pos.x - 290; // Fetch real data from /node-details API let liveData = node.data(); try { const resp = await fetch(`${API_BASE}/node-details/${node.data('id')}`); const json = await resp.json(); if (json.data) liveData = { ...node.data(), ...json.data, _detailType: json.type }; } catch(e) { console.warn('Node details fetch failed:', e); } setPopup({ node, data: liveData, conns: node.connectedEdges().length, x, y }); }); cy.on('tap', (evt) => { if (evt.target === cy) { cy.elements().removeClass('dimmed').removeClass('highlighted'); setPopup(null); } }); cy.on('zoom pan', () => setPopup(null)); }) .catch(err => console.error("Could not fetch graph:", err)); return () => { if (cyRef.current) cyRef.current.destroy(); } }, [setCyInstance]); const handleFilter = (type) => { setFilter(type); cyRef.current.elements().removeClass('dimmed').removeClass('highlighted'); if (type !== 'All') { cyRef.current.nodes().forEach(n => { if (n.data('type') !== type) n.addClass('dimmed'); }); } }; const toggleLabels = () => { const next = !labelsVisible; setLabelsVisible(next); if (next) cyRef.current.nodes().removeClass('hide-label'); else cyRef.current.nodes().addClass('hide-label'); }; const getBadgeColor = (type) => { switch(type) { case 'SalesOrder': return '#3b82f6'; case 'Delivery': return '#22c55e'; case 'BillingDoc': return '#f59e0b'; case 'JournalEntry': return '#8b5cf6'; case 'Payment': return '#f87171'; case 'Customer': return '#22c55e'; default: return '#1a1a1a'; } }; const exploreNeighbors = () => { if (!popup || !cyRef.current) return; const n = cyRef.current.getElementById(popup.data.id); const neighbors = n.connectedEdges().connectedNodes(); neighbors.addClass('highlighted'); n.removeClass('dimmed').addClass('highlighted'); cyRef.current.animate({ fit: { eles: neighbors.union(n), padding: 50 } }, { duration: 800 }); }; return (
{/* Controls */}
{/* Filters */}
{['All', 'SalesOrder', 'Delivery', 'BillingDoc', 'JournalEntry', 'Payment'].map(t => ( ))}
{/* Popup */} {popup && (
{popup.data.type}
{popup.data.id}
setPopup(null)} />
{Object.entries(popup.data).filter(([k]) => !['id','type','label'].includes(k)).slice(0, 8).map(([k, v]) => (
{k} {v}
))}
Additional fields hidden for readability
Connections: {popup.conns}
)} {/* Legend */}
Sales Order
Delivery
Billing Doc
Journal Entry
Payment
Customer
SO Item
Product/Material
{/* Counts */}
{counts.nodes} nodes · {counts.edges} edges
); }; export default GraphPanel;