stillerman commited on
Commit
e707a20
·
1 Parent(s): 7318d59
Files changed (1) hide show
  1. src/components/force-directed-graph.tsx +395 -289
src/components/force-directed-graph.tsx CHANGED
@@ -1,7 +1,11 @@
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
- import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d";
 
 
 
 
5
  import { Run } from "./reasoning-trace";
6
  import * as d3 from "d3";
7
  // This is a placeholder component for the force-directed graph
@@ -9,11 +13,11 @@ import * as d3 from "d3";
9
 
10
  // CSS variables for styling
11
  const STYLES = {
12
- fixedNodeColor: '#e63946', // Red
13
- fluidNodeColor: '#457b9d', // Steel Blue
14
- linkColor: '#adb5bd', // Grey
15
- highlightColor: '#fca311', // Orange/Yellow
16
- successColor: '#2a9d8f', // Teal
17
  minNodeOpacity: 0.3,
18
  minLinkOpacity: 0.15,
19
  };
@@ -26,7 +30,7 @@ interface ForceDirectedGraphProps {
26
  // Extended node and link types that include run metadata
27
  interface GraphNode extends NodeObject {
28
  id: string;
29
- type?: 'fixed' | 'fluid';
30
  radius?: number;
31
  baseOpacity?: number;
32
  runIds: number[]; // Array of run indices this node is part of
@@ -38,296 +42,398 @@ interface GraphNode extends NodeObject {
38
  interface GraphLink extends LinkObject {
39
  source: string | GraphNode;
40
  target: string | GraphNode;
41
- runIds: number[]; // Array of run indices this link is part of
42
  }
43
 
44
- export default function ForceDirectedGraph({runs, runId }: ForceDirectedGraphProps) {
45
- const [graphData, setGraphData] = useState<{nodes: GraphNode[], links: GraphLink[]}>({nodes: [], links: []});
46
- const containerRef = useRef<HTMLDivElement>(null);
47
- const graphRef = useRef<ForceGraphMethods<GraphNode, GraphLink>>(null);
48
-
49
- // Build graph data ONLY when runs change, not when runId changes
50
- useEffect(() => {
51
- const newGraphData: {nodes: GraphNode[], links: GraphLink[]} = {nodes: [], links: []};
52
- const nodesMap = new Map<string, GraphNode>();
53
- const linksMap = new Map<string, GraphLink>();
54
- const nodeDegrees: Map<string, number> = new Map();
55
- const mainNodeSet: Set<string> = new Set();
56
-
57
- // First identify all main nodes (start and destination)
58
- runs.forEach((run) => {
59
- mainNodeSet.add(run.start_article);
60
- mainNodeSet.add(run.destination_article);
61
- });
62
-
63
- // Process all runs to build data with metadata
64
- runs.forEach((run, runIndex) => {
65
- for(let i = 0; i < run.steps.length - 1; i++) {
66
- const step = run.steps[i];
67
- const nextStep = run.steps[i + 1];
68
-
69
- // Update or create source node
70
- if (!nodesMap.has(step.article)) {
71
- const isMainNode = mainNodeSet.has(step.article);
72
- nodesMap.set(step.article, {
73
- id: step.article,
74
- type: isMainNode ? 'fixed' : 'fluid',
75
- radius: isMainNode ? 7 : 5,
76
- runIds: [runIndex],
77
- isMainNode
78
- });
79
- } else {
80
- const node = nodesMap.get(step.article)!;
81
- if (!node.runIds.includes(runIndex)) {
82
- node.runIds.push(runIndex);
83
- }
84
- }
85
-
86
- // Update or create target node
87
- if (!nodesMap.has(nextStep.article)) {
88
- const isMainNode = mainNodeSet.has(nextStep.article);
89
- nodesMap.set(nextStep.article, {
90
- id: nextStep.article,
91
- type: isMainNode ? 'fixed' : 'fluid',
92
- radius: isMainNode ? 7 : 5,
93
- runIds: [runIndex],
94
- isMainNode
95
- });
96
- } else {
97
- const node = nodesMap.get(nextStep.article)!;
98
- if (!node.runIds.includes(runIndex)) {
99
- node.runIds.push(runIndex);
100
- }
101
- }
102
-
103
- // Update degrees for sizing/opacity calculations
104
- nodeDegrees.set(step.article, (nodeDegrees.get(step.article) || 0) + 1);
105
- nodeDegrees.set(nextStep.article, (nodeDegrees.get(nextStep.article) || 0) + 1);
106
-
107
- // Create or update link
108
- const linkId = `${step.article}->${nextStep.article}`;
109
- if (!linksMap.has(linkId)) {
110
- linksMap.set(linkId, {
111
- source: step.article,
112
- target: nextStep.article,
113
- runIds: [runIndex],
114
- id: linkId
115
- });
116
- } else {
117
- const link = linksMap.get(linkId)!;
118
- if (!link.runIds.includes(runIndex)) {
119
- link.runIds.push(runIndex);
120
- }
121
- }
122
- }
123
- });
124
-
125
- // Position main nodes in a circle
126
- const mainNodes = Array.from(mainNodeSet);
127
- const radius = 400; // Radius of the circle
128
- const centerX = 0; // Center X coordinate
129
- const centerY = 0; // Center Y coordinate
130
-
131
- mainNodes.forEach((nodeId, index) => {
132
- const angle = (index * 2 * Math.PI) / mainNodes.length;
133
- const node = nodesMap.get(nodeId);
134
- if (node) {
135
- node.fx = centerX + radius * Math.cos(angle);
136
- node.fy = centerY + radius * Math.sin(angle);
137
- }
138
- });
139
-
140
- // Create opacity scale based on node degrees
141
- const maxDegree = Math.max(...Array.from(nodeDegrees.values()));
142
- const opacityScale = d3.scaleLinear()
143
- .domain([1, Math.max(1, maxDegree)])
144
- .range([STYLES.minNodeOpacity, 1.0])
145
- .clamp(true);
146
-
147
- // Set base opacity for all nodes
148
- nodesMap.forEach(node => {
149
- node.baseOpacity = node.type === 'fixed' ?
150
- 1.0 : opacityScale(nodeDegrees.get(node.id) || 1);
151
- });
152
-
153
- // Convert maps to arrays for the graph
154
- newGraphData.nodes = Array.from(nodesMap.values());
155
- const links = Array.from(linksMap.values());
156
-
157
- // Convert string IDs to actual node objects in links
158
- newGraphData.links = links.map(link => {
159
- const sourceNode = nodesMap.get(link.source as string);
160
- const targetNode = nodesMap.get(link.target as string);
161
-
162
- // Only create links when both nodes exist
163
- if (sourceNode && targetNode) {
164
- return {
165
- ...link,
166
- source: sourceNode,
167
- target: targetNode
168
- };
169
- }
170
- // Skip this link if nodes don't exist
171
- return null;
172
- }).filter(Boolean) as GraphLink[];
173
-
174
- setGraphData(newGraphData);
175
- }, [runs]); // Only depends on runs, not runId
176
-
177
- // Set up the force simulation
178
- useEffect(() => {
179
- if (graphRef.current && graphData.nodes.length > 0) {
180
- const radialForceStrength = 0.7;
181
- const radialTargetRadius = 40;
182
- const linkDistance = 35;
183
- const chargeStrength = -100;
184
- const COLLISION_PADDING = 3;
185
-
186
- // Initialize force simulation
187
- graphRef.current.d3Force("link", d3.forceLink(graphData.links).id((d: any) => d.id).distance(linkDistance).strength(0.9));
188
- graphRef.current.d3Force("charge", d3.forceManyBody().strength(chargeStrength));
189
- graphRef.current.d3Force("radial", d3.forceRadial(radialTargetRadius, 0, 0).strength(radialForceStrength));
190
- graphRef.current.d3Force("collide", d3.forceCollide().radius((d: any) => (d.radius || 5) + COLLISION_PADDING));
191
- graphRef.current.d3Force("center", d3.forceCenter(0, 0));
192
-
193
- // Give the simulation a bit of time to stabilize, then zoom to fit
194
- setTimeout(() => {
195
- if (graphRef.current) {
196
- graphRef.current.zoomToFit(400);
197
- }
198
- }, 500);
199
  }
200
- }, [graphData, graphRef.current]);
201
 
202
- // Full page resize handler
203
- useEffect(() => {
204
- const handleResize = () => {
205
- if (graphRef.current) {
206
- graphRef.current.zoomToFit(400);
207
- }
208
- };
209
-
210
- window.addEventListener('resize', handleResize);
211
- return () => window.removeEventListener('resize', handleResize);
212
- }, []);
213
-
214
- // Helper function to determine node color based on current runId
215
- const getNodeColor = (node: GraphNode) => {
216
- if (runId !== null && node.runIds.includes(runId)) {
217
- // If the node is part of the selected run
218
- if (node.isMainNode) {
219
- // Main nodes (start/destination) of the selected run get highlight color
220
- const run = runs[runId];
221
- if (node.id === run.start_article || node.id === run.destination_article) {
222
- return STYLES.highlightColor;
223
- }
224
- }
225
- // Regular nodes in the selected run get highlight color
226
- return STYLES.highlightColor;
227
  }
228
-
229
- // Nodes not in the selected run get their default colors
230
- return node.type === 'fixed' ? STYLES.fixedNodeColor : STYLES.fluidNodeColor;
231
- };
232
 
233
- // Helper function to determine link color based on current runId
234
- const getLinkColor = (link: GraphLink) => {
235
- return runId !== null && link.runIds.includes(runId) ?
236
- STYLES.highlightColor : STYLES.linkColor;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  };
238
 
239
- // Helper function to determine if a node is in the current run
240
- const isNodeInCurrentRun = (node: GraphNode) => {
241
- // Handle case where node might be a string ID
242
- if (typeof node === 'string') return false;
243
- return runId !== null && node.runIds && node.runIds.includes(runId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  };
245
 
246
- return (
247
- <div className="w-full h-full flex items-center justify-center">
248
- <div ref={containerRef} className="w-full h-full">
249
- <ForceGraph2D
250
- ref={graphRef}
251
- graphData={graphData}
252
- nodeLabel="id"
253
- linkLabel="id"
254
- nodeColor={getNodeColor}
255
- linkColor={getLinkColor}
256
- linkWidth={link => {
257
- const source = typeof link.source === 'object' ? link.source : null;
258
- const target = typeof link.target === 'object' ? link.target : null;
259
- return (source && isNodeInCurrentRun(source)) || (target && isNodeInCurrentRun(target)) ? 2.5 : 1;
260
- }}
261
- nodeRelSize={1}
262
- linkDirectionalParticles={link => {
263
- const source = typeof link.source === 'object' ? link.source : null;
264
- const target = typeof link.target === 'object' ? link.target : null;
265
- return (source && isNodeInCurrentRun(source)) || (target && isNodeInCurrentRun(target)) ? 4 : 0;
266
- }}
267
- linkDirectionalParticleWidth={2}
268
- nodeCanvasObject={(node, ctx, globalScale) => {
269
- const label = node.id;
270
- const fontSize = 12 / globalScale;
271
- ctx.font = `${fontSize}px Sans-Serif`;
272
- const textWidth = ctx.measureText(label).width;
273
- const bckgDimensions = [textWidth, fontSize].map(
274
- (n) => n + fontSize * 0.2
275
- );
276
-
277
- // Apply opacity based on node type and properties
278
- const opacity =
279
- node.baseOpacity !== undefined
280
- ? node.baseOpacity
281
- : node.type === "fixed"
282
- ? 1.0
283
- : STYLES.minNodeOpacity;
284
-
285
- // Draw node circle with appropriate styling
286
- const radius = node.radius || (node.type === "fixed" ? 7 : 5);
287
- ctx.beginPath();
288
- ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI);
289
- ctx.fillStyle = getNodeColor(node);
290
- ctx.fill();
291
-
292
- // Add white stroke around nodes
293
- ctx.strokeStyle = "#fff";
294
- ctx.globalAlpha = opacity;
295
- ctx.lineWidth = 1;
296
- ctx.stroke();
297
-
298
- // Draw label with background for better visibility
299
- const shouldShowLabel =
300
- node.type === "fixed" ||
301
- (runId !== null &&
302
- node.id === runs[runId]?.start_article) ||
303
- (runId !== null &&
304
- node.id === runs[runId]?.destination_article);
305
-
306
- if (shouldShowLabel) {
307
- // Draw label background
308
- ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
309
- ctx.fillRect(
310
- node.x! - bckgDimensions[0] / 2,
311
- node.y! + 8,
312
- bckgDimensions[0],
313
- bckgDimensions[1]
314
- );
315
-
316
- // Draw label text
317
- ctx.textAlign = "center";
318
- ctx.textBaseline = "middle";
319
- ctx.fillStyle = "black";
320
- ctx.fillText(
321
- label,
322
- node.x!,
323
- node.y! + 8 + fontSize / 2
324
- );
325
- }
326
- }}
327
- width={containerRef.current?.clientWidth || 800}
328
- height={containerRef.current?.clientHeight || 800}
329
- />
330
- </div>
331
- </div>
332
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  }
 
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
+ import ForceGraph2D, {
5
+ ForceGraphMethods,
6
+ LinkObject,
7
+ NodeObject,
8
+ } from "react-force-graph-2d";
9
  import { Run } from "./reasoning-trace";
10
  import * as d3 from "d3";
11
  // This is a placeholder component for the force-directed graph
 
13
 
14
  // CSS variables for styling
15
  const STYLES = {
16
+ fixedNodeColor: "#e63946", // Red
17
+ fluidNodeColor: "#457b9d", // Steel Blue
18
+ linkColor: "#adb5bd", // Grey
19
+ highlightColor: "#fca311", // Orange/Yellow
20
+ successColor: "#2a9d8f", // Teal
21
  minNodeOpacity: 0.3,
22
  minLinkOpacity: 0.15,
23
  };
 
30
  // Extended node and link types that include run metadata
31
  interface GraphNode extends NodeObject {
32
  id: string;
33
+ type?: "fixed" | "fluid";
34
  radius?: number;
35
  baseOpacity?: number;
36
  runIds: number[]; // Array of run indices this node is part of
 
42
  interface GraphLink extends LinkObject {
43
  source: string | GraphNode;
44
  target: string | GraphNode;
45
+ runId: number; // Array of run indices this link is part of
46
  }
47
 
48
+ export default function ForceDirectedGraph({
49
+ runs,
50
+ runId,
51
+ }: ForceDirectedGraphProps) {
52
+ const [graphData, setGraphData] = useState<{
53
+ nodes: GraphNode[];
54
+ links: GraphLink[];
55
+ }>({ nodes: [], links: [] });
56
+ const containerRef = useRef<HTMLDivElement>(null);
57
+ const graphRef = useRef<ForceGraphMethods<GraphNode, GraphLink>>(null);
58
+
59
+ // Build graph data ONLY when runs change, not when runId changes
60
+ useEffect(() => {
61
+ // mock all the data
62
+ const nodesMap: Map<string, GraphNode> = new Map();
63
+ const linksList: GraphLink[] = [];
64
+ const mainNodes: Set<string> = new Set();
65
+
66
+ for (let runIndex = 0; runIndex < runs.length; runIndex++) {
67
+ const run = runs[runIndex];
68
+ const sourceArticle = run.start_article;
69
+ const destinationArticle = run.destination_article;
70
+ mainNodes.add(sourceArticle);
71
+ mainNodes.add(destinationArticle);
72
+
73
+ for (let i = 0; i < run.steps.length - 1; i++) {
74
+ const step = run.steps[i];
75
+ const nextStep = run.steps[i + 1];
76
+
77
+ if (!nodesMap.has(step.article)) {
78
+ nodesMap.set(step.article, { id: step.article, type: "fluid", radius: 5, runIds: [runIndex] });
79
+ } else {
80
+ const node = nodesMap.get(step.article)!;
81
+ if (!node.runIds.includes(runIndex)) {
82
+ node.runIds.push(runIndex);
83
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  }
 
85
 
86
+ if (!nodesMap.has(nextStep.article)) {
87
+ nodesMap.set(nextStep.article, { id: nextStep.article, type: "fluid", radius: 5, runIds: [runIndex] });
88
+ } else {
89
+ const node = nodesMap.get(nextStep.article)!;
90
+ if (!node.runIds.includes(runIndex)) {
91
+ node.runIds.push(runIndex);
92
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  }
 
 
 
 
94
 
95
+ linksList.push({ source: step.article, target: nextStep.article, runId: runIndex });
96
+ }
97
+ }
98
+
99
+ mainNodes.forEach((node) => {
100
+ const oldNode = nodesMap.get(node)!;
101
+ nodesMap.set(node, { ...oldNode, type: "fixed", radius: 7, isMainNode: true });
102
+ });
103
+
104
+ // position the main nodes in a circle
105
+ const radius = 400;
106
+ const centerX = 0;
107
+ const centerY = 0;
108
+ const mainNodesArray = Array.from(mainNodes);
109
+ const angle = 2 * Math.PI / mainNodesArray.length;
110
+ mainNodesArray.forEach((node, index) => {
111
+ const nodeObj = nodesMap.get(node)!;
112
+ nodeObj.fx = centerX + radius * Math.cos(angle * index);
113
+ nodeObj.fy = centerY + radius * Math.sin(angle * index);
114
+ });
115
+
116
+ // for (let i = 0; i < 10; i++) {
117
+ // nodes.push({ id: `node${i}`, type: i === 0 || i === 9 ? "fixed" : "fluid", radius: 5, runIds: [0] });
118
+ // }
119
+
120
+ // for (let i = 0; i < 9; i++) {
121
+ // links.push({
122
+ // source: `node${i}`,
123
+ // target: `node${i + 1}`,
124
+ // runId: 0,
125
+ // });
126
+ // }
127
+
128
+
129
+
130
+
131
+
132
+
133
+ const tmpGraphData: { nodes: GraphNode[]; links: GraphLink[] } = {
134
+ nodes: Array.from(nodesMap.values()),
135
+ links: linksList,
136
  };
137
 
138
+ setGraphData(tmpGraphData);
139
+
140
+ return;
141
+ // const newGraphData: { nodes: GraphNode[]; links: GraphLink[] } = {
142
+ // nodes: [],
143
+ // links: [],
144
+ // };
145
+ // const nodesMap = new Map<string, GraphNode>();
146
+ // const linksList: GraphLink[] = [];
147
+ // // const mainNodeSet: Set<GraphNode> = new Set();
148
+
149
+ // // First identify all main nodes (start and destination)
150
+ // // runs.forEach((run, runIndex) => {
151
+ // // mainNodeSet.add({
152
+ // // id: run.start_article,
153
+ // // type: "fixed",
154
+ // // radius: 7,
155
+ // // runIds: [runIndex],
156
+ // // isMainNode: true,
157
+ // // });
158
+ // // mainNodeSet.add({
159
+ // // id: run.destination_article,
160
+ // // type: "fixed",
161
+ // // radius: 7,
162
+ // // runIds: [runIndex],
163
+ // // isMainNode: true,
164
+ // // });
165
+ // // });
166
+
167
+ // // Process all runs to build data with metadata
168
+ // runs.forEach((run, runIndex) => {
169
+ // for (let i = 0; i < run.steps.length - 1; i++) {
170
+ // const step = run.steps[i];
171
+ // const nextStep = run.steps[i + 1];
172
+
173
+ // // Update or create source node
174
+ // if (!nodesMap.has(step.article)) {
175
+ // const isMainNode = i === 0 || i === run.steps.length - 2;
176
+ // nodesMap.set(step.article, {
177
+ // id: step.article,
178
+ // type: isMainNode ? "fixed" : "fluid",
179
+ // radius: isMainNode ? 7 : 5,
180
+ // runIds: [runIndex],
181
+ // isMainNode,
182
+ // });
183
+ // } else {
184
+ // const node = nodesMap.get(step.article)!;
185
+ // if (!node.runIds.includes(runIndex)) {
186
+ // node.runIds.push(runIndex);
187
+ // }
188
+ // }
189
+
190
+ // // Update or create target node
191
+ // if (!nodesMap.has(nextStep.article)) {
192
+ // const isMainNode = i === 0;
193
+ // nodesMap.set(nextStep.article, {
194
+ // id: nextStep.article,
195
+ // type: isMainNode ? "fixed" : "fluid",
196
+ // radius: isMainNode ? 7 : 5,
197
+ // runIds: [runIndex],
198
+ // isMainNode,
199
+ // });
200
+ // } else {
201
+ // const node = nodesMap.get(nextStep.article)!;
202
+ // if (!node.runIds.includes(runIndex)) {
203
+ // node.runIds.push(runIndex);
204
+ // }
205
+ // }
206
+
207
+ // // Create or update link
208
+ // const linkId = `${step.article}->${nextStep.article}`;
209
+ // linksList.push({
210
+ // source: step.article,
211
+ // target: nextStep.article,
212
+ // runId: runIndex,
213
+ // id: linkId,
214
+ // });
215
+
216
+ // // if (!linksMap.has(linkId)) {
217
+ // // linksMap.set(linkId, {
218
+ // // source: step.article,
219
+ // // target: nextStep.article,
220
+ // // runIds: [runIndex],
221
+ // // id: linkId
222
+ // // });
223
+ // // } else {
224
+ // // const link = linksMap.get(linkId)!;
225
+ // // if (!link.runIds.includes(runIndex)) {
226
+ // // link.runIds.push(runIndex);
227
+ // // }
228
+ // // }
229
+ // }
230
+ // });
231
+
232
+ // // Position main nodes in a circle
233
+ // // const mainNodes = Array.from(mainNodeSet);
234
+ // const radius = 400; // Radius of the circle
235
+ // const centerX = 0; // Center X coordinate
236
+ // const centerY = 0; // Center Y coordinate
237
+
238
+ // const mainNodes = Array.from(nodesMap.values()).filter(
239
+ // (node) => node.type === "fixed"
240
+ // );
241
+
242
+ // mainNodes.forEach((node, index) => {
243
+ // const angle = (index * 2 * Math.PI) / mainNodes.length;
244
+ // if (node) {
245
+ // node.fx = centerX + radius * Math.cos(angle);
246
+ // node.fy = centerY + radius * Math.sin(angle);
247
+ // }
248
+ // });
249
+
250
+ // // Convert maps to arrays for the graph
251
+ // newGraphData.nodes = Array.from(nodesMap.values());
252
+ // newGraphData.links = linksList;
253
+
254
+ // // Convert string IDs to actual node objects in links
255
+ // newGraphData.links = linksList
256
+ // .map((link) => {
257
+ // const sourceNode = nodesMap.get(link.source as string);
258
+ // const targetNode = nodesMap.get(link.target as string);
259
+
260
+ // // Only create links when both nodes exist
261
+ // if (sourceNode && targetNode) {
262
+ // return {
263
+ // ...link,
264
+ // source: sourceNode,
265
+ // target: targetNode,
266
+ // };
267
+ // }
268
+ // // Skip this link if nodes don't exist
269
+ // return null;
270
+ // })
271
+ // .filter(Boolean) as GraphLink[];
272
+
273
+ // setGraphData(newGraphData);
274
+ }, [runs]); // Only depends on runs, not runId
275
+
276
+ // Set up the force simulation
277
+ useEffect(() => {
278
+ if (graphRef.current && graphData.nodes.length > 0) {
279
+ // wait 100ms
280
+ setTimeout(() => {
281
+ const radialForceStrength = 0.7;
282
+ const radialTargetRadius = 40;
283
+ const linkDistance = 35;
284
+ const chargeStrength = -100;
285
+ const COLLISION_PADDING = 3;
286
+
287
+ // Initialize force simulation
288
+ graphRef.current.d3Force(
289
+ "link",
290
+ d3
291
+ .forceLink(graphData.links)
292
+ .id((d: any) => d.id)
293
+ .distance(linkDistance)
294
+ .strength(0.9)
295
+ );
296
+ graphRef.current.d3Force(
297
+ "charge",
298
+ d3.forceManyBody().strength(chargeStrength)
299
+ );
300
+ graphRef.current.d3Force(
301
+ "radial",
302
+ d3.forceRadial(radialTargetRadius, 0, 0).strength(radialForceStrength)
303
+ );
304
+ graphRef.current.d3Force(
305
+ "collide",
306
+ d3
307
+ .forceCollide()
308
+ .radius((d: any) => (d.radius || 5) + COLLISION_PADDING)
309
+ );
310
+ graphRef.current.d3Force("center", d3.forceCenter(0, 0));
311
+
312
+ // Give the simulation a bit of time to stabilize, then zoom to fit
313
+ setTimeout(() => {
314
+ if (graphRef.current) {
315
+ graphRef.current.zoomToFit(500,50);
316
+ }
317
+ }, 500);
318
+ }, 100);
319
+ }
320
+ }, [graphData]);
321
+
322
+ // Full page resize handler
323
+ useEffect(() => {
324
+ const handleResize = () => {
325
+ if (graphRef.current) {
326
+ graphRef.current.zoomToFit(400);
327
+ }
328
  };
329
 
330
+ window.addEventListener("resize", handleResize);
331
+ return () => window.removeEventListener("resize", handleResize);
332
+ }, []);
333
+
334
+ // Helper function to determine node color based on current runId
335
+ const getNodeColor = (node: GraphNode) => {
336
+ if (runId !== null && node.runIds.includes(runId)) {
337
+ // If the node is part of the selected run
338
+ if (node.isMainNode) {
339
+ // Main nodes (start/destination) of the selected run get highlight color
340
+ const run = runs[runId];
341
+ if (
342
+ node.id === run.start_article ||
343
+ node.id === run.destination_article
344
+ ) {
345
+ return STYLES.highlightColor;
346
+ }
347
+ }
348
+ // Regular nodes in the selected run get highlight color
349
+ return STYLES.highlightColor;
350
+ }
351
+
352
+ // Nodes not in the selected run get their default colors
353
+ return node.type === "fixed"
354
+ ? STYLES.fixedNodeColor
355
+ : STYLES.fluidNodeColor;
356
+ };
357
+
358
+ // Helper function to determine link color based on current runId
359
+ const getLinkColor = (link: GraphLink) => {
360
+ return runId !== null && link.runId === runId
361
+ ? STYLES.highlightColor
362
+ : STYLES.linkColor;
363
+ };
364
+
365
+
366
+ const isLinkInCurrentRun = (link: GraphLink) => {
367
+ return runId !== null && link.runId === runId;
368
+ };
369
+
370
+ return (
371
+ <div className="w-full h-full flex items-center justify-center">
372
+ <div ref={containerRef} className="w-full h-full">
373
+ <ForceGraph2D
374
+ ref={graphRef}
375
+ graphData={graphData}
376
+ nodeLabel="id"
377
+ nodeColor={getNodeColor}
378
+ linkColor={getLinkColor}
379
+ // nodeRelSize={getNodeSize}
380
+ linkWidth={(link) => {
381
+ return isLinkInCurrentRun(link) ? 4 : 1;
382
+ }}
383
+ nodeRelSize={5}
384
+ nodeCanvasObject={(node, ctx, globalScale) => {
385
+ const label = node.id;
386
+ const fontSize = 12 / globalScale;
387
+ ctx.font = `${fontSize}px Sans-Serif`;
388
+ const textWidth = ctx.measureText(label).width;
389
+ const bckgDimensions = [textWidth, fontSize].map(
390
+ (n) => n + fontSize * 0.2
391
+ );
392
+
393
+ const isInCurrentRun = node.runIds.includes(runId);
394
+
395
+ // Apply opacity based on node type and properties
396
+ const opacity = isInCurrentRun ? 1.0 : STYLES.minNodeOpacity;
397
+
398
+ // Draw node circle with appropriate styling
399
+ ctx.globalAlpha = opacity;
400
+ const radius = node.radius || (node.type === "fixed" ? 7 : 5);
401
+ ctx.beginPath();
402
+ ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI);
403
+ ctx.fillStyle = node.isMainNode ? STYLES.fixedNodeColor : STYLES.fluidNodeColor;
404
+ ctx.fill();
405
+
406
+ // Add white stroke around nodes
407
+ ctx.strokeStyle = isInCurrentRun ? STYLES.highlightColor : "transparent";
408
+ ctx.globalAlpha = opacity;
409
+ ctx.lineWidth = 3;
410
+ ctx.stroke();
411
+
412
+ // Draw label with background for better visibility
413
+ const shouldShowLabel =
414
+ node.type === "fixed" || isInCurrentRun;
415
+
416
+ if (shouldShowLabel) {
417
+ // Draw label background
418
+ ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
419
+ ctx.fillRect(
420
+ node.x! - bckgDimensions[0] / 2,
421
+ node.y! + 8,
422
+ bckgDimensions[0],
423
+ bckgDimensions[1]
424
+ );
425
+
426
+ // Draw label text
427
+ ctx.textAlign = "center";
428
+ ctx.textBaseline = "middle";
429
+ ctx.fillStyle = "black";
430
+ ctx.fillText(label, node.x!, node.y! + 8 + fontSize / 2);
431
+ }
432
+ }}
433
+ width={containerRef.current?.clientWidth || 800}
434
+ height={containerRef.current?.clientHeight || 800}
435
+ />
436
+ </div>
437
+ </div>
438
+ );
439
  }