File size: 11,097 Bytes
1c04688
 
8de92d1
e707a20
 
 
 
 
8de92d1
cb1d20a
1c04688
 
 
6beea84
 
e707a20
 
 
 
 
6beea84
 
 
 
1c04688
 
8de92d1
1c04688
 
a3afbd9
 
 
e707a20
a3afbd9
 
 
 
 
 
 
 
 
 
 
e707a20
a3afbd9
 
e707a20
 
 
 
 
 
 
 
 
 
fc5fea2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8de92d1
cb1d20a
e707a20
 
 
 
 
 
 
cb1d20a
a3afbd9
e707a20
 
 
 
 
 
e40b300
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3afbd9
 
e707a20
 
 
fc5fea2
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc5fea2
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc5fea2
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
fc5fea2
 
 
 
 
 
 
e707a20
 
 
 
 
 
a3afbd9
cb1d20a
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc5fea2
 
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8565da5
e707a20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc5fea2
 
 
 
 
 
e707a20
 
 
 
1c04688
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
"use client";

import { useEffect, useRef, useState } from "react";
import ForceGraph2D, {
  ForceGraphMethods,
  LinkObject,
  NodeObject,
} from "react-force-graph-2d";
import { Run } from "./reasoning-trace";
import * as d3 from "d3";
// This is a placeholder component for the force-directed graph
// In a real implementation, you would use a library like D3.js or react-force-graph

// CSS variables for styling
const STYLES = {
  fixedNodeColor: "#e63946", // Red
  fluidNodeColor: "#457b9d", // Steel Blue
  linkColor: "#adb5bd", // Grey
  highlightColor: "#fca311", // Orange/Yellow
  successColor: "#2a9d8f", // Teal
  minNodeOpacity: 0.3,
  minLinkOpacity: 0.15,
};

interface ForceDirectedGraphProps {
  runId: number | null;
  runs: Run[];
}

// Extended node and link types that include run metadata
interface GraphNode extends NodeObject {
  id: string;
  type?: "fixed" | "fluid";
  radius?: number;
  baseOpacity?: number;
  runIds: number[]; // Array of run indices this node is part of
  isMainNode?: boolean; // Whether this is a start/destination node
  fx?: number;
  fy?: number;
}

interface GraphLink extends LinkObject {
  source: string | GraphNode;
  target: string | GraphNode;
  runId: number; // Array of run indices this link is part of
}

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>
  );
}