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
}
← All docsView source on GitHub →