Page MenuHomec4science

ForceGraph.js
No OneTemporary

File Metadata

Created
Sun, Sep 1, 07:59

ForceGraph.js

import React, {useRef, useEffect, useState} from 'react';
import * as d3 from 'd3';
import './ForceGraph.css'
var colorConceptsPeople = {'concept': '#8eb9fc', 'object': '#e0805a', 'secondaryConcept': '#b0caff'}
function ForceGraph({dataDisplayed, tableColumns, searchEntity}) {
const svgRef = useRef(null);
const nodeGroupRef = useRef(null);
const linkGroupRef = useRef(null);
const simulationRef = useRef(null);
const [conceptThreshold, setConceptThreshold] = useState(1); // Initialize to show all nodes initially
const [objectThreshold, setObjectThreshold] = useState(1);
const [showLinks, setShowLinks] = useState(true);
const [nodeSize, setNodeSize] = useState(1);
const [maxStrength, setMaxStrength] = useState(1);
const [minRadius, setMinRadius] = useState(5);
const [maxRadius, setMaxRadius] = useState(20);
function findNeighbors(node) {
return linkGroupRef.current.data().reduce((neighbors, link) => {
if (link.target.id === node.id) {
neighbors.push(link.source.id)
} else if (link.source.id === node.id) {
neighbors.push(link.target.id)
}
return neighbors;
}, [node.id]); // include the node itself
}
// console.log("dataDisplayed", dataDisplayed)
// console.log("tableColumns", tableColumns)
useEffect(() => {
// setMaxStrength(d3.max(nodes, d => d.size));
setMinRadius(5 * Math.sqrt(nodeSize));
setMaxRadius(20 * Math.sqrt(nodeSize));
}, [nodeSize, maxStrength])
const maxConceptRank = tableColumns.length; // Assuming maxConceptRank holds the max rank for concept nodes
const maxObjectRank = dataDisplayed.length; // Assuming maxObjectRank holds the max rank for object nodes
const redrawGraph = () => {
if (nodeGroupRef.current === null) return;
nodeGroupRef.current.selectAll('.nodeGroup')
.style("display", d => {
if (d.type === 'concept' && d.rank > conceptThreshold) return "none";
if (d.type === 'object' && d.rank > objectThreshold) return "none";
return "block";
});
nodeGroupRef.current.selectAll("circle")
.attr("r", d => d.size)
nodeGroupRef.current.selectAll('text')
.text(d => {
// console.log('d', d)
if (d.type === 'concept') {
return d.id.split("_").join(" ");
}
return d.name;
})
.call(wrap, 10)
}
function wrap(text, width) {
text.each(function () {
var gnodeParent = d3.select(this.parentNode);
// console.log("gnodeParent", gnodeParent)
var gnodeParentData = gnodeParent._groups[0][0].__data__;
// console.log("gnodeParentData", gnodeParentData)
var text = d3.select(this),
words = text.text().split(" ").reverse(),
nwords = words.length,
word,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
x = text.attr("x"),
y = text.attr("y"),
dy = -(nwords + 1 / 2) * lineHeight / 2, //parseFloat(text.attr("dy")),
tspan = text.text(null)
.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", dy + "em");
while (word = words.pop()) {
// console.log("word", word)
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
tspan.text(line.join(" "));
line = [word];
// create a span with d3
tspan = text.append("tspan")
.text(word)
.attr("font-weight", "bold")
.attr("font-size", parseInt(gnodeParentData.size / 3) + "px")
.attr("dx", function () {
return -this.getComputedTextLength() / 2
})
.attr("x", 0)
.attr("y", 0)
.attr("dy", ++lineNumber * lineHeight + dy + "em");
}
}
});
}
function computeNodeSize(nodes, links) {
// Initialize a strength to 0 for each node
nodes.forEach(node => {
node.size = 1;
});
// This assumes you have defined your nodes and links from the data as before.
const nodeById = new Map(nodes.map(node => [node.id, node]));
links.forEach(link => {
link.source = nodeById.get(link.source);
link.target = nodeById.get(link.target);
});
// Iterate over links to sum the strength to the linked nodes
links.forEach(link => {
link.source.size += link.strength;
link.target.size += link.strength;
});
// First, let's separate nodes by their type
const conceptNodes = nodes.filter(node => node.type === 'concept');
const objectNodes = nodes.filter(node => node.type === 'object');
// Then, let's sort the nodes in descending order based on their size
conceptNodes.sort((a, b) => b.size - a.size);
objectNodes.sort((a, b) => b.size - a.size);
// Finally, assign the rank based on the sorted position
conceptNodes.forEach((node, index) => {
node.rank = index;
});
objectNodes.forEach((node, index) => {
node.rank = index;
});
const localMinRadius = 5 * Math.sqrt(nodeSize);
const localMaxRadius = 20 * Math.sqrt(nodeSize);
const localMaxStrength = d3.max(nodes, d => d.size);
const radiusScale = d3.scaleLinear()
.domain([0, localMaxStrength])
.range([localMinRadius, localMaxRadius]);
nodes.forEach(node => {
node.size = radiusScale(node.size);
});
}
useEffect(() => {
if (!dataDisplayed || dataDisplayed.length === 0) return;
const width = 800;
const height = 600;
const nodes = [];
const links = [];
// Create nodes from dataDisplayed
dataDisplayed.forEach((item) => {
// console.log("item", item)
let myNode = {id: item.ID, type: "object"};
if (searchEntity === 'courses') {
myNode.name = item.SubjectName;
} else if (searchEntity === 'labs') {
myNode.name = item.ID;
} else if (searchEntity === 'publications') {
myNode.name = item.Title;
} else if (['research', 'teaching'].includes(searchEntity)) {
myNode.name = item.FullName;
}
nodes.push(myNode);
tableColumns.forEach((column) => {
if (item[column] > 0) {
// Create node if doesn't exist
if (!nodes.some(node => node.id === column)) {
nodes.push({id: column, type: "concept"});
}
// Create link
links.push({source: item.ID, target: column, strength: item[column]});
}
});
});
computeNodeSize(nodes, links);
// Setup simulation
simulationRef.current = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links)
.id(d => d.id)
.strength(d => d.strength)
)
.force("charge", d3.forceManyBody().strength(
function (node) {
if (node.type === 'concept') {
// return -10;
return -1 * (node.size) ** (1.1) * 10
} else {
return -node.size * 5
// return 0;
}
}
))
.force("radialCenter", d3.forceRadial(0, width / 2, height / 2
).strength(function (d) {
return 0.15;
}))
.force("center", d3.forceCenter(width / 2, height / 2)
.strength(1)
)
.force('collide', d3.forceCollide().radius(d => {
console.log('d.size', d.size)
return d.size
})
.strength(1.5)
)
;
// Warm-up the simulation
for (let i = 0; i < 300; i++) {
simulationRef.current.tick();
}
const svg = d3.select(svgRef.current)
.attr("width", width)
.attr("height", height);
svg.selectAll("*").remove();
const zoomGroup = svg.append('g')
.attr('id', 'zoomGroup');
// Create links (lines)
linkGroupRef.current = zoomGroup.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.join("line")
.attr("stroke-width", d => Math.sqrt(d.value));
// Create node groups
nodeGroupRef.current = zoomGroup.append("g")
// .attr("stroke", "#fff")
// .attr("stroke-width", 1.5)
.selectAll(".nodeGroup")
.data(nodes)
.join("g")
.attr('class', 'nodeGroup')
.call(drag(simulationRef.current));
// Within each node group, create a circle
nodeGroupRef.current.append("circle")
.attr("r", d => d.size)
.attr("fill", d => colorConceptsPeople[d.type]);
// Within each node group, create a text element for the node's name
nodeGroupRef.current.append('text')
// .attr('text-anchor', 'middle') // Centers the text on the node
.attr('fill', 'black')
.text(d => {
// console.log('d', d)
if (d.type === 'concept') {
return d.id.split("_").join(" ");
}
return d.name;
})
.call(wrap, 10); // Adjust the width (10 in this case) to fit your requirements
// Update the tick function
simulationRef.current.on("tick", () => {
linkGroupRef.current.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
nodeGroupRef.current.each(function (d) { // .each() is used to access the data for each node
d.x = Math.max(5, Math.min(width - 5, d.x));
d.y = Math.max(5, Math.min(height - 5, d.y));
}).attr("transform", d => `translate(${d.x},${d.y})`);
linkGroupRef.current.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
nodeGroupRef.current.attr("transform", d => `translate(${Math.max(5, Math.min(width - 5, d.x))},${Math.max(5, Math.min(height - 5, d.y))})`);
});
function zoomed(event) {
zoomGroup.attr('transform', event.transform);
}
const zoom = d3.zoom()
.scaleExtent([0.1, 10]) // This defines the range of the zoom (0.1x to 10x).
.on('zoom', zoomed);
svg.call(zoom);
zoomGroup.attr('transform', 'translate(0, 0) scale(1)');
function drag(simulation) {
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0).alphaMin(-1).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
// If you want to make the node fixed after dragging, comment out the next two lines.
// d.fx = null;
// d.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
;
let isNodeFocused = false; // to keep track of node focus state
nodeGroupRef.current.on('click', function (event, d) {
event.stopPropagation(); // Prevent the graph from being reset when clicking on a node
if (isNodeFocused) {
// Reset opacities for nodes and links
nodeGroupRef.current.attr('opacity', 1);
linkGroupRef.current.attr('opacity', 1);
isNodeFocused = false;
} else {
const neighbors = findNeighbors(d);
nodeGroupRef.current.attr('opacity', node => neighbors.includes(node.id) ? 1 : 0.1);
linkGroupRef.current.attr('opacity', link => d.id === link.source.id || d.id === link.target.id ? 1 : 0.1);
isNodeFocused = true;
}
});
if (svgRef.current) {
// SVG click event to reset opacities
d3.select(svgRef.current).on('click', function (event) {
if (!isNodeFocused) return;
// Reset opacities for nodes and links
nodeGroupRef.current.attr('opacity', 1);
linkGroupRef.current.attr('opacity', 1);
isNodeFocused = false;
});
}
}, [dataDisplayed, tableColumns]);
useEffect(() => {
const updateNodeLinkVisibility = () => {
// Update node visibility
const nodeSelection = d3.select(svgRef.current).selectAll('.nodeGroup')
.style("display", d => {
if (d.type === 'concept' && d.rank >= conceptThreshold) return "none";
if (d.type === 'object' && d.rank >= objectThreshold) return "none";
return "block";
});
// Update link visibility based on node visibility
d3.select(svgRef.current).selectAll('line') // Assuming links are represented as line elements
.style("display", d => {
if (!showLinks) return "none";
// Check visibility for source and target nodes
const sourceNode = nodeSelection.filter(n => n.id === d.source.id).node();
const targetNode = nodeSelection.filter(n => n.id === d.target.id).node();
if (sourceNode && targetNode) {
// If either source or target node is hidden, hide the link
if (sourceNode.style.display === "none" || targetNode.style.display === "none") {
return "none";
}
}
return "block";
});
}
if (svgRef.current) {
updateNodeLinkVisibility()
}
}, [conceptThreshold, objectThreshold, dataDisplayed, showLinks]);
useEffect(() => {
if (nodeGroupRef.current && linkGroupRef.current) {
console.log('nodeGroupRef.current', nodeGroupRef.current.data())
console.log('linkGroupRef.current', linkGroupRef.current.data())
computeNodeSize(nodeGroupRef.current.data(), linkGroupRef.current.selectAll("line").data())
simulationRef.current
.force("charge", d3.forceManyBody().strength(node => {
if (node.type === 'concept') {
return -1 * (node.size) ** (1.1) * 100;
} else {
return -node.size * 5;
}
}))
.force('collide', d3.forceCollide().radius(d => d.size).strength(1.5));
}
}, [nodeSize])
const handleIncrease = (type) => {
if (type === 'concept' && conceptThreshold < maxConceptRank) {
setConceptThreshold(prev => prev + 1);
redrawGraph();
} else if (type === 'object' && objectThreshold < maxObjectRank) {
setObjectThreshold(prev => prev + 1);
redrawGraph();
} else if (type === 'size' && nodeSize < 10) {
setNodeSize(prev => prev + 1);
redrawGraph();
}
// console.log('conceptThreshold', conceptThreshold)
// console.log('objectThreshold', objectThreshold)
}
const handleDecrease = (type) => {
if (type === 'concept' && conceptThreshold > 1) {
setConceptThreshold(prev => prev - 1);
redrawGraph();
} else if (type === 'object' && objectThreshold > 1) {
setObjectThreshold(prev => prev - 1);
redrawGraph();
} else if (type === 'size' && nodeSize > 1) {
setNodeSize(prev => prev - 1);
redrawGraph();
}
}
const handleShowLinksChange = () => {
setShowLinks(!showLinks);
// Logic to show/hide links in your graph here.
};
const handleRecomputePositioning = () => {
// Logic to recompute graph positioning here.
simulationRef.current.alpha(1).restart();
for (let i = 0; i < 300; i++) {
simulationRef.current.tick();
}
};
useEffect(() => {
redrawGraph();
}, [nodeSize, maxStrength])
return (
<div style={{display: 'flex', height: '100vh'}}> {/* Container div with flex layout */}
{/* Sliders container */}
<div style={{flex: '0 0 20%', padding: '10px'}}> {/* Taking up 20% of container width */}
<label style={{margin: '0 10px'}}>Number of Nodes: </label>
<div style={{display: 'flex', alignItems: 'center'}}>
<button className='plusMinusButton' onClick={() => handleDecrease('concept')}>-</button>
<button className='plusMinusButton' onClick={() => handleIncrease('concept')}>+</button>
<input
id='conceptSlider'
type="range"
min="1"
max={maxConceptRank}
value={conceptThreshold}
onChange={(e) => {
setConceptThreshold(parseInt(e.target.value));
redrawGraph();
}}
/>
{conceptThreshold}
</div>
<div style={{display: 'flex', alignItems: 'center'}}>
<button className='plusMinusButton' onClick={() => handleDecrease('object')}>-</button>
<button className='plusMinusButton' onClick={() => handleIncrease('object')}>+</button>
<input
id='objectSlider'
type="range"
min="1"
max={maxObjectRank} // Assuming maxObjectRank holds the max rank for object nodes
value={objectThreshold}
onChange={(e) => {
setObjectThreshold(parseInt(e.target.value));
redrawGraph();
}}
/>
{objectThreshold}
</div>
<br/>
<label style={{margin: '0 10px'}}>Size of Nodes: </label>
<div style={{display: 'flex', alignItems: 'center'}}>
<button className='plusMinusButton' onClick={() => handleDecrease('size')}>-</button>
<button className='plusMinusButton' onClick={() => handleIncrease('size')}>+</button>
<input
id='sizeSlider'
type="range"
min='1'
max={10} // Assuming maxObjectRank holds the max rank for object nodes
value={nodeSize}
onChange={(e) => {
setNodeSize(parseInt(e.target.value));
redrawGraph();
}}
/>
{nodeSize}
</div>
<div style={{margin: '10px 0'}}>
<input
type="checkbox"
id='showLinksCheckbox'
checked={showLinks}
onChange={handleShowLinksChange}
/>
<label htmlFor='showLinksCheckbox'>Show Links</label>
</div>
<div style={{margin: '10px 0'}}>
<button onClick={handleRecomputePositioning}>
Recompute Positioning
</button>
</div>
</div>
{/* SVG container */}
<div style={{flex: '1', overflow: 'hidden'}}> {/* Taking up remaining space */}
<svg ref={svgRef}></svg>
</div>
</div>
);
}
export default ForceGraph;

Event Timeline