export default function ForceDirectedGraph()

in src/components/force-directed-graph.tsx [48:345]


export default function ForceDirectedGraph({
  runs,
  runId,
}: ForceDirectedGraphProps) {
  const [graphData, setGraphData] = useState<{
    nodes: GraphNode[];
    links: GraphLink[];
  }>({ nodes: [], links: [] });
  const containerRef = useRef<HTMLDivElement>(null);
  const graphRef = useRef<ForceGraphMethods<GraphNode, GraphLink>>(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  // Track container dimensions
  useEffect(() => {
    if (!containerRef.current) return;
    
    const updateDimensions = () => {
      if (containerRef.current) {
        const parent = containerRef.current.parentElement;
        const width = parent ? parent.clientWidth : containerRef.current.clientWidth;
        const height = parent ? parent.clientHeight : containerRef.current.clientHeight;
        
        // Constrain dimensions to reasonable values
        const constrainedWidth = Math.min(width, window.innerWidth);
        const constrainedHeight = Math.min(height, window.innerHeight);
        
        setDimensions({
          width: constrainedWidth,
          height: constrainedHeight,
        });
      }
    };
    
    // Initial measurement
    updateDimensions();
    
    // Set up resize observer
    const resizeObserver = new ResizeObserver(updateDimensions);
    resizeObserver.observe(containerRef.current);
    
    if (containerRef.current.parentElement) {
      resizeObserver.observe(containerRef.current.parentElement);
    }
    
    // Clean up
    return () => {
      if (containerRef.current) {
        resizeObserver.unobserve(containerRef.current);
        if (containerRef.current.parentElement) {
          resizeObserver.unobserve(containerRef.current.parentElement);
        }
      }
      resizeObserver.disconnect();
    };
  }, []);

  // Build graph data ONLY when runs change, not when runId changes
  useEffect(() => {
    // mock all the data
    const nodesMap: Map<string, GraphNode> = new Map();
    const linksList: GraphLink[] = [];
    const mainNodes: Set<string> = new Set();

    for (let runIndex = 0; runIndex < runs.length; runIndex++) {
      const run = runs[runIndex];
      const sourceArticle = run.start_article;
      const destinationArticle = run.destination_article;
      mainNodes.add(sourceArticle);
      mainNodes.add(destinationArticle);

      for (let i = 0; i < run.steps.length - 1; i++) {
        const step = run.steps[i];
        const nextStep = run.steps[i + 1];

        if (!nodesMap.has(step.article)) {
          nodesMap.set(step.article, { id: step.article, type: "fluid", radius: 5, runIds: [runIndex] });
        } else {
          const node = nodesMap.get(step.article)!;
          if (!node.runIds.includes(runIndex)) {
            node.runIds.push(runIndex);
          }
        }

        if (!nodesMap.has(nextStep.article)) {
          nodesMap.set(nextStep.article, { id: nextStep.article, type: "fluid", radius: 5, runIds: [runIndex] });
        } else {
          const node = nodesMap.get(nextStep.article)!;
          if (!node.runIds.includes(runIndex)) {
            node.runIds.push(runIndex);
          }
        }

        linksList.push({ source: step.article, target: nextStep.article, runId: runIndex });
      }
    }

    mainNodes.forEach((node) => {
        const oldNode = nodesMap.get(node)!;
        nodesMap.set(node, { ...oldNode, id: node, type: "fixed", radius: 7, isMainNode: true });
    });

    // position the main nodes in a circle
    const radius = 400;
    const centerX = 0;
    const centerY = 0;
    const mainNodesArray = Array.from(mainNodes);
    const angle = 2 * Math.PI / mainNodesArray.length;
    mainNodesArray.forEach((node, index) => {
      const nodeObj = nodesMap.get(node)!;
      nodeObj.fx = centerX + radius * Math.cos(angle * index);
      nodeObj.fy = centerY + radius * Math.sin(angle * index);
    });

    const tmpGraphData: { nodes: GraphNode[]; links: GraphLink[] } = {
      nodes: Array.from(nodesMap.values()),
      links: linksList,
    };

    setGraphData(tmpGraphData);

    return;
   
  }, [runs]); // Only depends on runs, not runId

  // Set up the force simulation
  useEffect(() => {
    if (graphRef.current && graphData.nodes.length > 0) {
        // wait 100ms
        setTimeout(() => {
      const radialForceStrength = 0.7;
      const radialTargetRadius = 40;
      const linkDistance = 35;
      const chargeStrength = -100;
      const COLLISION_PADDING = 3;

      // Initialize force simulation
      graphRef.current.d3Force(
        "link",
        d3
          .forceLink(graphData.links)
          .id((d: GraphNode) => d.id)
          .distance(linkDistance)
          .strength(0.9)
      );
      graphRef.current.d3Force(
        "charge",
        d3.forceManyBody().strength(chargeStrength)
      );
      graphRef.current.d3Force(
        "radial",
        d3.forceRadial(radialTargetRadius, 0, 0).strength(radialForceStrength)
      );
      graphRef.current.d3Force(
        "collide",
        d3
          .forceCollide()
          .radius((d: GraphNode) => (d.radius || 5) + COLLISION_PADDING)
      );
      graphRef.current.d3Force("center", d3.forceCenter(0, 0));

      // Give the simulation a bit of time to stabilize, then zoom to fit
      setTimeout(() => {
        if (graphRef.current) {
          graphRef.current.zoomToFit(500,50);
        }
          }, 500);
        }, 100);
    }
  }, [graphData]);

  // Recenter graph when dimensions change
  useEffect(() => {
    if (dimensions.width > 0 && dimensions.height > 0 && graphRef.current) {
      graphRef.current.zoomToFit(400);
    }
  }, [dimensions]);

  // Full page resize handler
  useEffect(() => {
    const handleResize = () => {
      if (graphRef.current) {
        graphRef.current.zoomToFit(400);
      }
    };

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  // Helper function to determine node color based on current runId
  const getNodeColor = (node: GraphNode) => {
    if (runId !== null && node.runIds.includes(runId)) {
      // If the node is part of the selected run
      if (node.isMainNode) {
        // Main nodes (start/destination) of the selected run get highlight color
        const run = runs[runId];
        if (
          node.id === run.start_article ||
          node.id === run.destination_article
        ) {
          return STYLES.highlightColor;
        }
      }
      // Regular nodes in the selected run get highlight color
      return STYLES.highlightColor;
    }

    // Nodes not in the selected run get their default colors
    return node.type === "fixed"
      ? STYLES.fixedNodeColor
      : STYLES.fluidNodeColor;
  };

  // Helper function to determine link color based on current runId
  const getLinkColor = (link: GraphLink) => {
    return runId !== null && link.runId === runId
      ? STYLES.highlightColor
      : STYLES.linkColor;
  };


  const isLinkInCurrentRun = (link: GraphLink) => {
    return runId !== null && link.runId === runId;
  };

  return (
    <div className="w-full h-full flex items-center justify-center relative overflow-hidden">
      <div ref={containerRef} className="w-full h-full absolute inset-0">
        <ForceGraph2D
          ref={graphRef}
          graphData={graphData}
          nodeLabel="id"
          nodeColor={getNodeColor}
          linkColor={getLinkColor}
          linkWidth={(link) => {
            return isLinkInCurrentRun(link) ? 4 : 1;
          }}
          nodeRelSize={5}
          nodeCanvasObject={(node, ctx, globalScale) => {
            const label = node.id;
            const fontSize = 12 / globalScale;
            ctx.font = `${fontSize}px Sans-Serif`;
            const textWidth = ctx.measureText(label).width;
            const bckgDimensions = [textWidth, fontSize].map(
              (n) => n + fontSize * 0.2
            );

            const isInCurrentRun = node.runIds?.includes(runId);

            // Apply opacity based on node type and properties
            const opacity = isInCurrentRun ? 1.0 : STYLES.minNodeOpacity;

            // Draw node circle with appropriate styling
            ctx.globalAlpha = opacity;
            const radius = node.radius || (node.type === "fixed" ? 7 : 5);
            ctx.beginPath();
            ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI);
            ctx.fillStyle = node.isMainNode ? STYLES.fixedNodeColor : STYLES.fluidNodeColor;
            ctx.fill();

            // Add white stroke around nodes
            ctx.strokeStyle = isInCurrentRun ? STYLES.highlightColor : "transparent";
            ctx.globalAlpha = opacity;
            ctx.lineWidth = 3;
            ctx.stroke();

            // Draw label with background for better visibility
            const shouldShowLabel =
              node.type === "fixed" || isInCurrentRun;

            if (shouldShowLabel) {
              // Draw label background
              ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
              ctx.fillRect(
                node.x! - bckgDimensions[0] / 2,
                node.y! + 8,
                bckgDimensions[0],
                bckgDimensions[1]
              );

              // Draw label text
              ctx.textAlign = "center";
              ctx.textBaseline = "middle";
              ctx.fillStyle = "black";
              ctx.fillText(label, node.x!, node.y! + 8 + fontSize / 2);
            }
          }}
          width={dimensions.width || containerRef.current?.clientWidth || 800}
          height={dimensions.height || containerRef.current?.clientHeight || 800}
          enableNodeDrag={true}
          enableZoomInteraction={true}
          cooldownTicks={100}
          cooldownTime={2000}
        />
      </div>
    </div>
  );
}