function ForceGraph(
{
nodes,
links
},
{
nodeId = (d) => d.id,
nodeColor,
nodeGroup,
nodeGroups,
nodeTitle,
nodeValue,
degreeCentrality,
closenessCentrality,
betweennessCentrality,
eigenvectorCentrality,
nodeFill = "currentColor",
nodeStroke = "#fff",
nodeStrokeWidth = 1.5,
nodeStrokeOpacity = 1,
nodeRadius = 5,
nodeStrength,
linkSource = ({ source }) => source,
linkTarget = ({ target }) => target,
linkStroke = "#999",
linkStrokeOpacity = 0.6,
linkStrokeWidth = 1.5,
linkStrokeLinecap = "round",
linkStrength,
colors = d3.schemeTableau10,
width = 640,
height = 400,
episodeName,
invalidation // when this promise resolves, stop the simulation
} = {}
) {
// Compute values.
const N = d3.map(nodes, nodeId).map(intern);
const LS = d3.map(links, linkSource).map(intern);
const LT = d3.map(links, linkTarget).map(intern);
const V = d3.map(nodes, nodeValue).map(intern);
if (nodeTitle === undefined) nodeTitle = (_, i) => N[i];
const T = nodeTitle == null ? null : d3.map(nodes, nodeTitle);
const G = nodeGroup == null ? null : d3.map(nodes, nodeGroup).map(intern);
const W =
typeof linkStrokeWidth !== "function"
? null
: d3.map(links, linkStrokeWidth);
const L = typeof linkStroke !== "function" ? null : d3.map(links, linkStroke);
const C = nodeColor == null ? null : d3.map(nodes, nodeColor).map(intern);
//getting node network metrics
const DC = d3.map(nodes, degreeCentrality).map(intern); // Degree centrality
const CC = d3.map(nodes, closenessCentrality).map(intern); // Closeness centrality
const BW = d3.map(nodes, betweennessCentrality).map(intern); // Betweeness
const EVC = d3.map(nodes, eigenvectorCentrality).map(intern); // Eigenvector centrality
// Replace the input nodes and links with mutable objects for the simulation.
nodes = d3.map(nodes, (_, i) => ({
id: N[i],
value: V[i],
degCen: DC[i],
cloCen: CC[i],
betCen: BW[i],
eigCen: EVC[i]
}));
links = d3.map(links, (_, i) => ({ source: LS[i], target: LT[i] }));
// Compute default domains.
if (G && nodeGroups === undefined) nodeGroups = d3.sort(G);
// Construct the scales.
const color = nodeGroup == null ? null : d3.scaleOrdinal(nodeGroups, colors);
// Construct the forces.
const forceNode = d3.forceManyBody().distanceMax(800);
const forceLink = d3.forceLink(links).id(({ index: i }) => N[i]);
if (nodeStrength !== undefined) forceNode.strength(nodeStrength);
if (linkStrength !== undefined) forceLink.strength(linkStrength);
const simulation = d3
.forceSimulation(nodes)
.force("link", forceLink)
.force("charge", forceNode)
// .force("center", d3.forceCenter())
.force(
"forceX",
d3
.forceX(function (d) {
return hasLinks(d, links) ? width / 20 : width / 5;
})
.strength(function (d) {
return hasLinks(d, links) ? 0.1 : 1;
})
)
.force(
"forceY",
d3
.forceY(function (d) {
return hasLinks(d, links) ? height / 10 : height / 3;
})
.strength(function (d) {
return hasLinks(d, links) ? 0.1 : 0.1;
})
)
.alpha(0.1)
.alphaDecay(0.03)
.on("tick", ticked);
// ****************** Special case for episode 1 ******************************************************************** //
if (episodeName === "Episode 1") {
console.log(episodeName);
simulation.force("center", d3.forceCenter());
}
// ****************** Special case for episode 3 ******************************************************************** //
// ****************** Episode 3 is HUGE. Without the custom code below, the graph goes all over the place! **************** //
if (episodeName === "Episode 3") {
console.log(episodeName);
simulation.force(
"forceX",
d3
.forceX(function (d) {
return hasLinks(d, links) ? width / 20 : width / 5;
})
.strength(function (d) {
return hasLinks(d, links) ? 0.1 : 1;
})
);
}
const svg = d3
.create("svg")
.attr("viewBox", [
(-width * 0.8) / 2,
(-height * 0.8) / 2,
width * 0.8,
height * 0.8
]);
const link = svg
.append("g")
.attr("stroke", typeof linkStroke !== "function" ? linkStroke : null)
.attr("stroke-opacity", linkStrokeOpacity)
.attr(
"stroke-width",
typeof linkStrokeWidth !== "function" ? linkStrokeWidth : null
)
.attr("stroke-linecap", linkStrokeLinecap)
.selectAll("line")
.data(links)
.join("line");
const node = svg
.append("g")
.attr("fill", nodeFill)
.attr("stroke", nodeStroke)
.attr("stroke-opacity", nodeStrokeOpacity)
.attr("stroke-width", nodeStrokeWidth)
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("r", numScenes)
.call(drag(simulation));
const text = svg
.selectAll("text")
.data(nodes)
.enter()
// .filter((d) => d.value >= 4)
.append("text")
.attr("font-family", "sans-serif")
.attr("dx", 12)
.attr("dy", "0.35em")
//.style("font-size", (d) => size(d.value + 10) + "pt")
.style("font-size", function (d) {
return d.value > 40
? size(d.value) + "pt"
: d.value > 25
? size(d.value + 10) + "pt"
: size(d.value + 5) + "pt"; //If-elseif-else statement to calc how much to move text
})
.style("fill", "#333")
.text((d) => d.id);
if (W) link.attr("stroke-width", ({ index: i }) => W[i]);
if (L) link.attr("stroke", ({ index: i }) => L[i]);
// if (G) node.attr("fill", ({index: i}) => color(G[i])); ##commenting out group colors as we have node colors in the json nodes
if (C) node.attr("fill", ({ index: i }) => color(C[i]));
if (T) node.append("title").text(({ index: i }) => T[i]);
if (invalidation != null) invalidation.then(() => simulation.stop());
function intern(value) {
return value !== null && typeof value === "object"
? value.valueOf()
: value;
}
// multiple eventlistener code from https://stackoverflow.com/questions/44978574/d3-js-passing-in-multiple-functions-on-hover
node.on("mouseover.circle", function (d) {
var d_name = d.srcElement.__data__.id;
// console.log(d_name);
var connectedNodeIds = links
.filter((x) => {
return x.source.id == d_name || x.target.id == d_name;
})
.map((x) => (x.source.id == d_name ? x.target.id : x.source.id));
d3.selectAll("circle").attr("fill", function (c) {
// console.log(connectedNodeIds.indexOf(c.id));
// console.log(c);
if (
c !== undefined &&
(connectedNodeIds.indexOf(c.id) > -1 || c.id == d_name)
)
return "red";
else return "lightgrey"; //color(c.colour);
});
});
// tooltip code from: https://observablehq.com/@mkane2/force-directed-graph-with-tooltip
// multiple eventlistener code from https://stackoverflow.com/questions/44978574/d3-js-passing-in-multiple-functions-on-hover
node.on("mouseover.text", function (event, d) {
var d_name = d.id;
var connectedNodeIds = links
.filter((x) => {
return x.source.id == d_name || x.target.id == d_name;
})
.map((x) => (x.source.id == d_name ? x.target.id : x.source.id));
var html_metrics_string = "";
if (d.degCen !== undefined) {
html_metrics_string =
"<h5> Degree :" +
Math.round(d.degCen * 1000) / 1000 +
"</h5> \
<h5> Betweeness :" +
Math.round(d.betCen * 1000) / 1000 +
"</h5>";
}
return tooltip
.html(
"<h4>" +
d_name +
": " +
connectedNodeIds.length +
"</h4>" +
html_metrics_string
)
.style("visibility", "visible") // make the tooltip visible on hover
.style("top", event.pageY - 20 + "px") // position the tooltip with its top at the same pixel location as the mouse on the screen
.style("left", event.pageX + 15 + "px"); // position the tooltip just to the right of the mouse location
});
node.on("mouseout.text", function () {
return tooltip
.transition()
.duration(50) // give the hide behavior a 50 milisecond delay so that it doesn't jump around as the network moves
.style("visibility", "hidden"); // hide the tooltip when the mouse stops hovering
});
node.on("mouseout.circle", function (d) {
node.attr("fill", ({ index: i }) => color(C[i]));
});
// const someData = svg.selectAll("rect")
// .data(nodes)
// .enter()
// .append("g")
// .append("rect")
// .attr("height", height*.8)
// .attr("width", width/4)
// .attr("fill", "lightblue")
// .attr("fill-opacity", 0.03)
// .attr("transform",
// function(d){ return "translate(" + height * d + ",0)";});
function ticked() {
link
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
node.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
text //here we want to shift the text by a few points on x-axis, if the circle is too big to avoid overwriting.
.attr("x", function (d) {
return d.value > 40 ? d.x + 20 : d.value > 25 ? d.x + 10 : d.x; //If-elseif-else statement to calc how much to move text
})
.attr("y", (d) => d.y);
}
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3
.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
function hasLinks(d, links) {
var isLinked = false;
links.forEach(function (l) {
if (l.source.id == d.id) {
isLinked = true;
}
});
return isLinked;
}
invalidation.then(() => simulation.stop());
return Object.assign(svg.node(), { scales: { color } });
}