Published
Edited
Aug 10, 2022
Insert cell
Insert cell
Insert cell
// viewof episode = Inputs.radio([1, 2, 3], {label: "Episode", value: 1})
viewof episode = Inputs.range([1, 7], { value: 1, step: 1, label: "Episode" })
Insert cell
chart2 = ForceGraph(graphs[episode].network, {
nodeId: (d) => d.id,
nodeColor: (d) => d.colour,
nodeGroup: (d) => d.group,
nodeTitle: (d) => `${d.id}\n${d.group}`,
nodeValue: (d) => d.value,
degreeCentrality: (d) => d.Degree,
closenessCentrality: (d) => d.Closeness,
betweennessCentrality: (d) => d.Betweenness,
eigenvectorCentrality: (d) => d.Eigenvector,
linkStrokeWidth: (l) => Math.sqrt(l.value),
nodeStrength: -500,
width: 1150,
height: 600,
episodeName: graphs[episode].name,
invalidation // a promise to stop the simulation when the cell is re-run
})
Insert cell
table_metrics = {
return Inputs.table(graphs[episode].metrics, {
format: {
degree_centrality: sparkbar(
d3.max(graphs[episode].metrics, (d) => d.Degree)
),
closeness_centrality: sparkbar(
d3.max(graphs[episode].metrics, (d) => d.Closeness)
),
betweenness_centrality: sparkbar(
d3.max(graphs[episode].metrics, (d) => d.Betweenness)
),
eigenvector_centrality: sparkbar(
d3.max(graphs[episode].metrics, (d) => d.Eigenvector)
)
}
});
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/force-directed-graph
function ForceGraph(
{
nodes, // an iterable of node objects (typically [{id}, …])
links // an iterable of link objects (typically [{source, target}, …])
},
{
nodeId = (d) => d.id, // given d in nodes, returns a unique identifier (string)
nodeColor,
nodeGroup, // given d in nodes, returns an (ordinal) value for color
nodeGroups, // an array of ordinal values representing the node groups
nodeTitle, // given d in nodes, a title string
nodeValue, // = d => d.value,
degreeCentrality,
closenessCentrality,
betweennessCentrality,
eigenvectorCentrality,
nodeFill = "currentColor", // node stroke fill (if not using a group color encoding)
nodeStroke = "#fff", // node stroke color
nodeStrokeWidth = 1.5, // node stroke width, in pixels
nodeStrokeOpacity = 1, // node stroke opacity
nodeRadius = 5, // node radius, in pixels
nodeStrength,
linkSource = ({ source }) => source, // given d in links, returns a node identifier string
linkTarget = ({ target }) => target, // given d in links, returns a node identifier string
linkStroke = "#999", // link stroke color
linkStrokeOpacity = 0.6, // link stroke opacity
linkStrokeWidth = 1.5, // given d in links, returns a stroke width in pixels
linkStrokeLinecap = "round", // link stroke linecap
linkStrength,
colors = d3.schemeTableau10, // an array of color strings, for the node groups
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
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 } });
}
Insert cell
numScenes = {
// var width = d3.scaleSqrt().domain([1, 300]).range([10, 20, 30, 35]);

var nodevals = [];
for (let a of graphs[episode].network.nodes) {
nodevals.push(a.value);
}

var width = d3
.scaleSqrt()
.domain([d3.min(nodevals), d3.max(nodevals)])
.range([5, 10, 15, 20, 25, 30, 35, 40, 45]);

return (d) => width(d.value);
}
Insert cell
size = d3
.scaleLinear()
.domain(d3.extent(graphs[episode].network.nodes, (d) => d.value))
.range([4, 18])
Insert cell
// **************** Tooltip code from: https://observablehq.com/@mkane2/force-directed-graph-with-tooltip
// **************** name a variable tooltip, and style it using css properties
// tooltip = d3
// .select("body")
// .append("div") // the tooltip always "exists" as its own html div, even when not visible
// .style("position", "absolute") // the absolute position is necessary so that we can manually define its position later
// // .style("visibility", "hidden") // hide it from default at the start so it only appears on hover
// .style("background-color", "white")
// .attr("class", "tooltip")
// .style("opacity", 0.7)

// **************** Above code doesnt work on Flask server sites, when we embed Observable. You have to wrap non-svg elements in a foreignObject tag, and you have to specify the html namespace when appending html elements **************** //

// https://stackoverflow.com/questions/41623548/why-is-my-tooltip-not-showing-up
tooltip = d3
.select("body")
.append("foreignObject")
.append("xhtml:div")
.style("position", "absolute") // the absolute position is necessary so that we can manually define its position later
// .style("visibility", "hidden") // hide it from default at the start so it only appears on hover
.style("background-color", "white")
.attr("class", "tooltip")
.style("opacity", 0.9)
Insert cell
// this is some optional tooltip styling to make it look nice. This just uses regular css
html`
<style type="text/css">
.tooltip {
fill: white;
font-family: sans-serif;
padding: 5px;
display: flex,
border-radius: 5px;
border: 1px solid grey;
}

</style>
`
Insert cell
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more