PathDetail.jsx
import { useEffect, useRef, useState } from 'react';
import cytoscape from 'cytoscape';
import elk from 'cytoscape-elk';
import EdgeBadge from './EdgeBadge';
import { usePathDetail } from '../hooks/useMetrics';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
// Register ELK layout
cytoscape.use(elk);
export default function PathDetail({ pathId, onClose }) {
const { detail, loading, error } = usePathDetail(pathId);
const cyRef = useRef(null);
const containerRef = useRef(null);
const [selectedEdge, setSelectedEdge] = useState(null);
useEffect(() => {
if (!detail || !containerRef.current) return;
// Build Cytoscape graph
const elements = buildGraphElements(detail.edges);
const cy = cytoscape({
container: containerRef.current,
elements,
style: [
{
selector: 'node',
style: {
'background-color': '#3b82f6',
label: 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
color: '#fff',
'font-size': '12px',
width: 80,
height: 40,
shape: 'roundrectangle',
},
},
{
selector: 'node[type="signal"]',
style: {
'background-color': '#8b5cf6',
},
},
{
selector: 'node[type="action"]',
style: {
'background-color': '#3b82f6',
},
},
{
selector: 'node[type="outcome"]',
style: {
'background-color': '#10b981',
},
},
{
selector: 'edge',
style: {
width: 3,
'line-color': (ele) => getEdgeColor(ele.data('delta_roi')),
'target-arrow-color': (ele) => getEdgeColor(ele.data('delta_roi')),
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
},
},
{
selector: 'edge:selected',
style: {
width: 5,
'line-color': '#f59e0b',
'target-arrow-color': '#f59e0b',
},
},
],
layout: {
name: 'elk',
elk: {
algorithm: 'layered',
'elk.direction': 'RIGHT',
'elk.spacing.nodeNode': 50,
},
},
});
// Handle edge clicks
cy.on('tap', 'edge', (evt) => {
const edge = evt.target.data();
setSelectedEdge(edge);
});
cy.on('tap', (evt) => {
if (evt.target === cy) {
setSelectedEdge(null);
}
});
cyRef.current = cy;
return () => {
if (cyRef.current) {
cyRef.current.destroy();
}
};
}, [detail]);
if (loading) {
return (
<div className="path-detail loading">
<div className="detail-header">
<h2>Loading...</h2>
</div>
<div className="skeleton skeleton-graph"></div>
</div>
);
}
if (error) {
return (
<div className="path-detail error">
<button className="close-btn" onClick={onClose}>
✕
</button>
<p>Error loading path detail: {error}</p>
</div>
);
}
return (
<div className="path-detail">
<div className="detail-header">
<div>
<h2>
Path: <code>{pathId}</code>
</h2>
<div className="path-metrics-summary">
<span className="metric-chip">
ΔROI: <strong>{detail.metrics.roi_delta?.toFixed(2) || 'N/A'}</strong>
</span>
<span className="metric-chip">
Velocity: <strong>{Math.round(detail.metrics.velocity_ms)}ms</strong>
</span>
<span className="metric-chip">
PCR: <strong>{(detail.metrics.pcr * 100).toFixed(0)}%</strong>
</span>
</div>
</div>
<button className="close-btn" onClick={onClose}>
✕
</button>
</div>
{/* Graph Visualization */}
<div className="graph-container" ref={containerRef}></div>
{/* Edge Detail Panel (when edge selected) */}
{selectedEdge && (
<div className="edge-detail-panel">
<EdgeBadge edge={selectedEdge} />
</div>
)}
{/* Timeseries */}
{detail.timeseries && detail.timeseries.length > 0 && (
<div className="timeseries-panel">
<h3>ROI Trend (30d)</h3>
<ResponsiveContainer width="100%" height={120}>
<LineChart data={detail.timeseries.reverse()}>
<XAxis
dataKey="date"
tickFormatter={(date) => new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
/>
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="weighted_roi"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="roi_7d_ma"
stroke="#f59e0b"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function buildGraphElements(edges) {
const nodes = new Map();
const cyEdges = [];
edges.forEach((edge) => {
// Add source node
if (!nodes.has(edge.src_node_id)) {
nodes.set(edge.src_node_id, {
data: {
id: edge.src_node_id,
label: edge.src_label,
type: getNodeType(edge.src_label),
},
});
}
// Add destination node
if (!nodes.has(edge.dst_node_id)) {
nodes.set(edge.dst_node_id, {
data: {
id: edge.dst_node_id,
label: edge.dst_label,
type: getNodeType(edge.dst_label),
},
});
}
// Add edge
cyEdges.push({
data: {
id: edge.edge_id,
source: edge.src_node_id,
target: edge.dst_node_id,
delta_roi: edge.latest_delta_roi,
latency_ms: edge.latest_latency_ms,
...edge,
},
});
});
return [...Array.from(nodes.values()), ...cyEdges];
}
function getNodeType(label) {
if (label.toLowerCase().includes('signal') || label.toLowerCase().includes('search') || label.toLowerCase().includes('open')) {
return 'signal';
}
if (label.toLowerCase().includes('revenue') || label.toLowerCase().includes('conversion')) {
return 'outcome';
}
return 'action';
}
function getEdgeColor(deltaRoi) {
if (deltaRoi > 2) return '#10b981'; // green
if (deltaRoi > 1) return '#3b82f6'; // blue
if (deltaRoi > 0) return '#f59e0b'; // yellow
return '#ef4444'; // red
}