In this post, I am dipping my toes into the world of compute shaders in WebGPU. This is the first of a series on building a particle simulation with collision detection using the GPU.
Read MoreEven if you aren't familiar with these, I'm a fan of jumping right in and learning as you go.
The nodes can be any data object, containing any data variables you want, as long as each item has a unique id.
[{"id": "0", "name": "nytimes", "count": 0, "category": 0}, {"id": "1", "name": "hbo", "count": 1, "category": 1}, {"id": "2", "name": "fenty beauty gloss bomb universal lip luminizer", "count": 1, "category": 1}, ...]
The links contain data for each pair of nodes - the source and target - that should be connected in the graph.
[{"source": "0", "target": "1", "value": 1, "count": 1}, {"source": "0", "target": "2", "value": 1, "count": 1}, ...]
We start off with an HTML outline.
<!DOCTYPE html> <head> <meta charset="utf-8"> <script src="https://d3js.org/d3.v4.min.js"></script> </head> <body> <div id="networkGraph"></div> </body> <script></script> </html>
<div id="networkGraph"></div>
.</html>
tag.const width = "960"; const height = "600"; const sourceRadius = 45; const entityRadius = 35; var svg = d3.select("#networkGraph") .append("svg") .attr("width", width) .attr("height", height);
I defined some constants here for the width and height of the SVG, and also defined the radius for the source nodes and the entity nodes.
In the graph you can see that the circles for the entities have a smaller radius than the circle for the article source, the NY Times.
var simulation = d3.forceSimulation() .force("link", d3.forceLink().id(function(d) { return d.id; })) .force('charge', d3.forceManyBody() .strength(-1900) .theta(0.5) .distanceMax(1500) ) .force('collision', d3.forceCollide().radius(function(d) { return d.radius })) .force("center", d3.forceCenter(document.querySelector("#networkGraph").clientWidth / 2, document.querySelector("#networkGraph").clientHeight / 2));
We have a few forces here that each will work on the nodes and links to move them into place and create the graph layout.
#networkGraph
div.These are the lines between the circles in our graph that link the circles together.
var link = svg.append("g") .selectAll("line") .data(graph.links) .enter().append("line") link .style("stroke", "#aaa");
An SVG line element is created for each link object in our data.
I added some styling. The "stroke" is just the line color.
If you're unclear what is going on so far with the code to draw the links - which will be similar to the next pieces of code to draw the nodes and labels - you will want to read more about data joins in D3. Here is a good post explaining data joins, written by the creator of D3.js, Mike Bostock.
var node = svg.append("g") .attr("class", "nodes") .selectAll("circle") .data(graph.nodes) .enter().append("circle") //I made the article/source nodes larger than the entity nodes .attr("r", function(d){return d.category==0 ? 45 : 35}); node .style("fill", "#cccccc") .style("fill-opacity","0.9") .style("stroke", "#424242") .style("stroke-width", "1px");
An SVG circle element is created for each node object in our data.
This is where the category variable from the nodes data comes in.
I have two categories in this data - 0(zero) and 1, corresponding to either nodes representing article sources as category 0(zero), and nodes representing entities from the articles as category 1.
The radius for category 0(zero) is 45 while other categories(in this case only one other category, 1 for the entities) are 35.
var label = svg.append("g") .attr("class", "labels") .selectAll("text") .data(graph.nodes) .enter().append("text") .text(function(d) { return d.name; }) .attr("class", "label") label .style("text-anchor", "middle") .style("font-size", "10px");
An SVG text element is created for each node object in our data, and it uses the name variable from the node object to assign the text content to display.
Where things fall into place.
Each 'tick' in the simulation moves the graph towards the desired layout and invokes the forces that we defined in our simulation earlier, computing the new x and y coordinates for each circle, line and label.
The simulation starts automatically, but you can also stop it and restart it.
You can read more about what the tick functionality is doing in the docs.
function ticked() { link .attr("x1", function(d) { return d.source.x; }) .attr("y1", function(d) { return d.source.y; }) .attr("x2", function(d) { return d.target.x; }) .attr("y2", function(d) { return d.target.y; }); node .attr("cx", function (d) { return d.x+5; }) .attr("cy", function(d) { return d.y-3; }); label .attr("x", function(d) { return d.x; }) .attr("y", function (d) { return d.y; }); }
This is our tick handler and it is responsible for keeping track of the state of our layout and re-drawing the circles and lines in their new x and y coordinates. This happens many times until the layout is complete.
When you first load the page, you can see the circles and lines moving around as the simulation guides them into place.
simulation .nodes(graph.nodes) .on("tick", ticked); simulation.force("link") .links(graph.links);
This is where the ticked
function is called and the simulation starts.
It's a network graph alright, but it could stand to be jazzed up a bit.
We will update the font-size attribute for our labels in the following way:
.style("font-size", function(d) {return d.category == 1 ? Math.min(2 * entityRadius, (2 * entityRadius - 8) / this.getComputedTextLength() * 15) + "px" : Math.min(2 * sourceRadius, (2 * sourceRadius - 8) / this.getComputedTextLength() * 15) + "px"; });
The important part of that line of code is:
Math.min(2 * radius, (2 * radius - 8) / this.getComputedTextLength() * 15)
The font-size is adjusted based on the radius of the circle and the computed text length, and is adapted from this post https://bl.ocks.org/mbostock/1846692.
After adjusting the font-size, some of the labels are a bit hard to read, but we can add some tooltips to remedy this.
var tooltip = d3.select("body") .append("div") .style("position", "absolute") .style("visibility", "hidden") .style("color", "white") .style("padding", "8px") .style("background-color", "#626D71") .style("border-radius", "6px") .style("text-align", "center") .style("width", "auto") .text("");
We defined a div
element here with absolute position that we will populate with text when we trigger the mouse events that we will define next.
label .on("mouseover", function(d){ tooltip.html(`${d.name}`); return tooltip.style("visibility", "visible");}) .on("mousemove", function(){ return tooltip.style("top", (d3.event.pageY-10)+"px").style("left",(d3.event.pageX+10)+"px");}) node .on("mouseover", function(d){ tooltip.html(`${d.name}`); return tooltip.style("visibility", "visible");}) .on("mousemove", function(){ return tooltip.style("top", (d3.event.pageY-10)+"px").style("left",(d3.event.pageX+10)+"px");}) .on("mouseout", function(){return tooltip.style("visibility", "hidden");});
mouseover
and mousemove
events to the node and label because they are separate entities in this graph and both need the events added to them.mouseout
event to the nodes so that the tooltip will disappear when the mouse moves out of the circle.Hover over the circles to see the them.
And finally, a gradient fill for the circles.
First we append a defs
element to our SVG.
var defs = svg.append("defs");
This element tells SVG that it is a resource and not a regular element, so we need to apply it to another element in order to see it. We will be applying it to the nodes - the circles in the graph.
I'm creating two different gradients with one for the article/source node(NY Times) and the other for the target/entity nodes.
defs.append("radialGradient") .attr("id", "source-gradient") .selectAll("stop") .data([ {offset: "20%", color: "#eda515"}, {offset: "100%", color: "#827777"}, ]) .enter().append("stop") .attr("offset", function(d) { return d.offset; }) .attr("stop-color", function(d) { return d.color; });
The stop
element defines a color and its offset or position in the gradient.
This one is a radial gradient, with an orange center - #eda515
and the rest of it is a grayish color #827777
.
I gave it an id of "source-gradient" which will be used to apply it to the node later.
This gradient is used for the article source node, which in this graph is the New York Times node.
defs.append("radialGradient") .attr("id", "entity-gradient") .selectAll("stop") .data([ {offset: "50%", color: "#ffffff"}, {offset: "100%", color: "#CCCCCC"}, ]) .enter().append("stop") .attr("offset", function(d) { return d.offset; }) .attr("stop-color", function(d) { return d.color; });
This is another radial gradient with an id of "entity-gradient".
This gradient will be applied to the entity nodes in the graph.
node .style("fill", function(d){return d.category==0 ? "url(#source-gradient)" : "url(#entity-gradient)"});
If you have any questions or comments, please leave a comment below or reach out to me on twitter @LVNGD.
In this post, I am dipping my toes into the world of compute shaders in WebGPU. This is the first of a series on building a particle simulation with collision detection using the GPU.
Read MoreFinding the Lowest Common Ancestor of a pair of nodes in a tree can be helpful in a variety of problems in areas such as information retrieval, where it is used with suffix trees for string matching. Read on for the basics of this in Python.
Read MoreThis blog post walks through the process of writing a fragment shader in GLSL, and using it within the three.js library for working with WebGL. We will render a visually appealing grid of rotating rectangles that can be used as a website background.
Read More