Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to achieve disc shape in D3 force simulation?

I'm trying to recreate the awesome 'dot flow' visualizations from Bussed out by Nadieh Bremer and Shirely Wu.

original bubbles

I'm especially intrigued by the very circular shape of the 'bubbles' and the fluid-dynamics-like compression in the spot where the dots arrive to the bubble (black arrow).

My take was to create (three) fixed nodes by .fx and .fy (black dots) and link all the other nodes to a respective fixed node. The result looks quite disheveled and the bubbles even don't form around their center nodes, when I lower the forces so the animation runs a little slower.

  const simulation = d3.forceSimulation(nodes)
    .force("collide", d3.forceCollide((n, i) => i < 3 ? 0 : 7))
    .force("links", d3.forceLink(links).strength(.06))

Any ideas on force setup which would yield more aesthetically pleasing results?

I do understand that I'll have to animate the group assignment over time to get the 'trickle' effect (otherwise all the points would just swarm to their destination), but i'd like to start with a nice and round steady state of the simulation.

EDIT

I did check the source code, and it's just replaying pre-recorded simulation data, I guess for performance reasons.

my result

like image 409
liborm Avatar asked Dec 24 '22 01:12

liborm


1 Answers

Building off of Gerardo's start,

I think that one of the key points, to avoid excessive entropy is to specify a velocity decay - this will help avoid overshooting the desired location. Too slow, you won't get an increase in density where the flow stops, too fast, and you have the nodes either get too jumbled or overshoot their destination, oscillating between too far and too short.

A many body force is useful here - it can keep the nodes spaced (rather than a collision force), with the repulsion between nodes being offset by positioning forces for each cluster. Below I have used two centering points and a node property to determine which one is used. These forces have to be fairly weak - strong forces lead to over correction quite easily.

Rather than using a timer, I'm using the simulation.find() functionality each tick to select one node from one cluster and switch which center it is attracted to. After 1000 ticks the simulation below will stop:

var canvas = d3.select("canvas");
var width = +canvas.attr("width");
var height = +canvas.attr("height");
var context = canvas.node().getContext('2d');

// Key variables:
var nodes = [];
var strength = -0.25;         // default repulsion
var centeringStrength = 0.01; // power of centering force for two clusters
var velocityDecay = 0.15;     // velocity decay: higher value, less overshooting
var outerRadius = 250;        // new nodes within this radius
var innerRadius = 100;        // new nodes outside this radius, initial nodes within.
var startCenter = [250,250];  // new nodes/initial nodes center point
var endCenter = [710,250];	  // destination center
var n = 200;		          // number of initial nodes
var cycles = 1000;	          // number of ticks before stopping.



// Create a random node:
var random = function() {
	var angle = Math.random() * Math.PI * 2;
	var distance = Math.random() * (outerRadius - innerRadius) + innerRadius;
	var x = Math.cos(angle) * distance + startCenter[0];
	var y = Math.sin(angle) * distance + startCenter[1];

	return { 
	   x: x,
	   y: y,
	   strength: strength,
	   migrated: false
	   }
}

// Initial nodes:
for(var i = 0; i < n; i++) {
	nodes.push(random());
}
	
var simulation = d3.forceSimulation()
    .force("charge", d3.forceManyBody().strength(function(d) { return d.strength; } ))
	.force("x1",d3.forceX().x(function(d) { return d.migrated ? endCenter[0] : startCenter[0] }).strength(centeringStrength))
	.force("y1",d3.forceY().y(function(d) { return d.migrated ? endCenter[1] : startCenter[1] }).strength(centeringStrength))
	.alphaDecay(0)
	.velocityDecay(velocityDecay)
    .nodes(nodes)
    .on("tick", ticked);

var tick = 0;
	
function ticked() {
	tick++;
	
	if(tick > cycles) this.stop();
	
	nodes.push(random()); // create a node
	this.nodes(nodes);    // update the nodes.

  var migrating = this.find((Math.random() - 0.5) * 50 + startCenter[0], (Math.random() - 0.5) * 50 + startCenter[1], 10);
  if(migrating) migrating.migrated = true;
  
	
	context.clearRect(0,0,width,height);
	
	nodes.forEach(function(d) {
		context.beginPath();
		context.fillStyle = d.migrated ? "steelblue" : "orange";
		context.arc(d.x,d.y,3,0,Math.PI*2);
		context.fill();
	})
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<canvas width="960" height="500"></canvas>

Here's a block view (snippet would be better full page, the parameters are meant for it). The initial nodes are formed in the same ring as later nodes (so there is a bit of jostle at the get go, but this is an easy fix). On each tick, one node is created and one attempt is made to migrate a node near the middle to other side - this way a stream is created (as opposed to any random node).

For fluids, unlinked nodes are probably best (I've been using it for wind simulation) - linked nodes are ideal for structured materials like nets or cloth. And, like Gerardo, I'm also a fan of Nadieh's work, but will have to keep an eye on Shirley's work as well in the future.

like image 65
Andrew Reid Avatar answered Dec 25 '22 16:12

Andrew Reid