stillerman commited on
Commit
a3afbd9
·
1 Parent(s): 6beea84

cheff kiss

Browse files
src/components/force-directed-graph.tsx CHANGED
@@ -23,105 +23,225 @@ interface ForceDirectedGraphProps {
23
  runs: Run[];
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  export default function ForceDirectedGraph({runs, runId }: ForceDirectedGraphProps) {
27
- const [graphData, setGraphData] = useState<{nodes: {id: string, color?: string, type?: string, radius?: number, baseOpacity?: number}[], links: {source: string, target: string, color?: string}[]}>({nodes: [], links: []});
28
- const [dimensions, setDimensions] = useState({ width: 800, height: 800 });
29
  const containerRef = useRef<HTMLDivElement>(null);
30
- const graphRef = useRef<ForceGraphMethods<NodeObject<{ id: string; color?: string; }>, LinkObject<{ id: string; color?: string; }, { source: string; target: string; color?: string; }>>>(null);
31
 
 
32
  useEffect(() => {
33
- const newGraphData: {nodes: {id: string, color?: string, type?: string, radius?: number, baseOpacity?: number}[], links: {source: string, target: string, color?: string}[]} = {nodes: [], links: []};
34
- const nodesSet: Set<string> = new Set();
 
 
35
  const mainNodeSet: Set<string> = new Set();
 
 
 
 
 
 
36
 
37
- if(runs) {
38
- // Create a map to track node degrees (number of connections)
39
- const nodeDegrees: Map<string, number> = new Map();
40
-
41
- runs.forEach((run, runIndex) => {
42
- // add in src and dst to nodes
43
- const isSelectedRun = runId === runIndex;
44
-
45
- mainNodeSet.add(run.start_article);
46
- mainNodeSet.add(run.destination_article);
47
-
48
- for(let i = 0; i < run.steps.length - 1; i++) {
49
- const step = run.steps[i];
50
- const nextStep = run.steps[i + 1];
51
-
52
- if(!mainNodeSet.has(step.article)) {
53
- nodesSet.add(step.article);
 
 
 
54
  }
55
-
56
- if(!mainNodeSet.has(nextStep.article)) {
57
- nodesSet.add(nextStep.article);
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  }
59
-
60
- // Increment degree count for both nodes
61
- nodeDegrees.set(step.article, (nodeDegrees.get(step.article) || 0) + 1);
62
- nodeDegrees.set(nextStep.article, (nodeDegrees.get(nextStep.article) || 0) + 1);
63
-
64
- newGraphData.links.push({
65
- source: step.article,
 
 
 
 
66
  target: nextStep.article,
67
- color: isSelectedRun ? STYLES.highlightColor : STYLES.linkColor
 
68
  });
 
 
 
 
 
69
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- const mainNodes = Array.from(mainNodeSet);
72
- const radius = 400; // Radius of the circle
73
- const centerX = 0; // Center X coordinate
74
- const centerY = 0; // Center Y coordinate
75
-
76
- newGraphData.nodes = mainNodes.map((id, index) => {
77
- const angle = (index * 2 * Math.PI) / mainNodes.length;
78
- return {
79
- id,
80
- fx: centerX + radius * Math.cos(angle),
81
- fy: centerY + radius * Math.sin(angle),
82
- type: 'fixed',
83
- radius: 7, // Larger radius for fixed nodes
84
- color: isSelectedRun && (id === run.start_article || id === run.destination_article) ? STYLES.highlightColor : STYLES.fixedNodeColor,
85
- baseOpacity: 1.0 // Fixed nodes are always fully visible
86
- };
87
- });
88
-
89
- // Create opacity scale based on node degrees
90
- const maxDegree = Math.max(...Array.from(nodeDegrees.values()));
91
- const opacityScale = d3.scaleLinear()
92
- .domain([1, Math.max(1, maxDegree)])
93
- .range([STYLES.minNodeOpacity, 1.0])
94
- .clamp(true);
95
-
96
- newGraphData.nodes.push(...Array.from(nodesSet).map((id) => ({
97
- id,
98
- type: 'fluid',
99
- radius: 5, // Smaller radius for fluid nodes
100
- color: isSelectedRun && run.steps.some(step => step.article === id) ? STYLES.highlightColor : STYLES.fluidNodeColor,
101
- baseOpacity: opacityScale(nodeDegrees.get(id) || 1)
102
- })));
103
- });
104
-
105
- setGraphData(newGraphData);
 
 
 
 
 
 
 
106
  }
107
- }, [runs, runId]);
108
 
 
109
  useEffect(() => {
110
- if (graphRef.current) {
111
- const radialForceStrength = 0.7;
112
- const radialTargetRadius = 40; // Increased radius to allow more space
113
- const linkDistance = 35; // Keep links relatively short
114
- const chargeStrength = -100; // Increase repulsion more
115
- const COLLISION_PADDING = 3;
116
-
117
- graphRef.current.zoomToFit();
118
-
119
- graphRef.current.d3Force("link", d3.forceLink(graphData.links).id((d) => d.id).distance(linkDistance).strength(0.9));
120
- graphRef.current.d3Force("charge", d3.forceManyBody().strength(chargeStrength));
121
- graphRef.current.d3Force("radial", d3.forceRadial(radialTargetRadius, 0, 0).strength(radialForceStrength));
122
- graphRef.current.d3Force("collide", d3.forceCollide().radius((d) => d.radius + COLLISION_PADDING));
 
 
 
 
 
 
 
 
 
 
123
  }
124
- }, [graphRef]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  return (
127
  <div className="w-full h-full flex items-center justify-center">
@@ -131,12 +251,19 @@ export default function ForceDirectedGraph({runs, runId }: ForceDirectedGraphPro
131
  graphData={graphData}
132
  nodeLabel="id"
133
  linkLabel="id"
134
- nodeColor="color"
135
- linkColor="color"
136
- linkWidth={link => link.source.id === runId || link.target.id === runId ? 2.5 : 1}
137
- linkOpacity={0.6}
 
 
 
138
  nodeRelSize={1}
139
- linkDirectionalParticles={link => link.source.id === runId || link.target.id === runId ? 4 : 0}
 
 
 
 
140
  linkDirectionalParticleWidth={2}
141
  nodeCanvasObject={(node, ctx, globalScale) => {
142
  const label = node.id;
@@ -156,11 +283,10 @@ export default function ForceDirectedGraph({runs, runId }: ForceDirectedGraphPro
156
  : STYLES.minNodeOpacity;
157
 
158
  // Draw node circle with appropriate styling
159
- const radius =
160
- node.radius || (node.type === "fixed" ? 7 : 5);
161
  ctx.beginPath();
162
  ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI);
163
- ctx.fillStyle = node.color || STYLES.fluidNodeColor;
164
  ctx.fill();
165
 
166
  // Add white stroke around nodes
@@ -198,8 +324,8 @@ export default function ForceDirectedGraph({runs, runId }: ForceDirectedGraphPro
198
  );
199
  }
200
  }}
201
- width={dimensions.width}
202
- height={dimensions.height}
203
  />
204
  </div>
205
  </div>
 
23
  runs: Run[];
24
  }
25
 
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
33
+ isMainNode?: boolean; // Whether this is a start/destination node
34
+ fx?: number;
35
+ fy?: number;
36
+ }
37
+
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">
 
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;
 
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
 
324
  );
325
  }
326
  }}
327
+ width={containerRef.current?.clientWidth || 800}
328
+ height={containerRef.current?.clientHeight || 800}
329
  />
330
  </div>
331
  </div>