Animated Orbiting Particles in d3 ~

So let's say I wanted to build an SVG atom in d3. Something like this:

A spritely Helium-4 atom. Not drawn to scale.

A basic atom diagram like this is more engaging and flexible than a static graphic. But suppose I wanted to digram any combination of these particles, like other types of atoms. Since it only takes three particles to make an atom (protons, neutrons, and electrons) this is a good use case for some object-oriented javascript.

Abstracting Particles

Let's start with declaring a Particle class.

var Particle = function(){ this.node = null; this.properties = { type: null, id: '', x: 0, y: 0, scale: 1 }; this.types = { proton: { circle: { r: 1, stroke_width: 0.1 }, text: { text: 'p+', x: -0.65, y: 0.4, font_size: 1.3 } }, neutron: { circle: { r: 0.9, stroke_width: 0.1 }, text: { text: 'n', x: -0.4, y: 0.35, font_size: 1.3 } }, electron: { circle: { r: 0.1, stroke_width: 0.02 }, text: { text: 'e-', x: -0.06, y: 0.045, font_size: 0.15 } } }; };

Our Particle class has three top-level attributes: node , properties , and types . We'll get to node and types in a minute, but first, why put object attributes like id and position inside another object instead of as top-level attributes? While it will make internal references to those properties slightly more verbose it allows us to emulate the d3 API's approach of both setting and getting any property with just a single method. Here's an example for setting and getting id :

Particle.prototype.id = function(value){ if (typeof value == "undefined"){ return this.properties.id; } else { this.properties.id = value.toString(); return this; } };

If no argument is passed to a method—e.g. Particle.id() —the Particle is unchanged and in this example the value of the Particle's id property is returned. If an argument is passed, however—e.g. Particle.id("foo") —the Particle's id attribute is set to the value. Also worth noting is the return value: this . This is the simple approach that allows chaining methods, also emulated from the d3 API and countless other javascript frameworks. It allows creating a Particle to be done flexibly in a single command, like this:

var p = new Particle().id("foo").x(10).y(20);

The full Particle class declaration is at the bottom of this blog post, so we'll skip the rest of the property getter/setter methods for now. Suffice it to say that every value in the properties object should have a corresponding getter/setter function, each with it's own input validation.

Particle Types and Rendering

Let's take a closer look at the three basic types defined in the Particle class's declaration:

this.types = { proton: { circle: { r: 1, stroke_width: 0.1 }, text: { text: 'p+', x: -0.65, y: 0.4, font_size: 1.3 } }, neutron: { circle: { r: 0.9, stroke_width: 0.1 }, text: { text: 'n', x: -0.4, y: 0.35, font_size: 1.3 } }, electron: { circle: { r: 0.1, stroke_width: 0.02 }, text: { text: 'e-', x: -0.06, y: 0.045, font_size: 0.15 } } };

Each type describes the basic appearance of a certain particle. For simplicity's sake all particles are essentially a circle and a piece of text. Since these would be literally appended to a parent SVG as <circle> and <text> objects we can define each of these with the attributes they'll need. Thus, the proton has a circle with r (radius) of 1 and stroke_width of 0.1.

There are a lot of magic numbers here, but there's a pattern. Consider the relative radii of each type's circle. Proton, at radius 1, is our unit particle. Electrons are way smaller than protons (about 1/2,000th the size), but that scale is a bit ridiculous for medium-sized diagrams, so let's just crank the electron down by an order of magnitude to 0.1. Now neutrons are actually a shade smaller than protons, and a good approximation is that a proton minus an electron is a neutron (hey, that's how beta decay works!), so let's do set the neutron radius to that difference of 0.9. All the other magic numbers sort of fall out from what looks "good" given those initial particle sizes. Especially when sizing and positioning text; SVG text can vary across browsers and the line-height-to-letter-height ratio varies depending on glyphs and fonts. When designing text in SVG one can mitigate sloppiness but never eliminate it.

At this point I had to select which attributes would be baked into the javascript declarations (possibly making them configurable with each Particle definition) or bake them into the CSS as classes. In general, since classes render faster than styles and are not defined inside logic, I favor using CSS as the default location for all attributes and pull only the ones I know I'll want to change on a per-particle basis into javascript. But since flexibility is awesome and inflexibility sucks the CSS ended up with mostly just colors.

text.particle { font-family: Helvetica, Arial, sans-serif; fill: rgb(15,15,15); } circle.particle.proton { fill: rgb(170,255,186); stroke: rgb(102,153,112); } circle.particle.neutron { fill: rgb(255,213,170); stroke: rgb(153,128,102); } circle.particle.electron { fill: rgb(170,227,255); stroke: rgb(102,136,153); }

Now to render a defined particle we can once again borrow from d3. Let's imagine the syntax we want and then make that syntax work (this is essentially test-driven development in a nutshell). Since I don't want to crawl inside the guts of d3 but I will be busy chaining methods to define a Particle, I could do something like this:

var p = new Particle().id("foo").appendTo(d3.select("svg"));

What we're essentially doing is finishing the method chain with a special function to actually append the particle group to the SVG. Also, since d3 selectors are objects that can be passed around easily, taking a selector as the only argument tells us where to do the rendering. This is powerful and important because in SVG layers and nesting are absolute determinants to the appearance of the final product, so our class will be far more robust if we can add particles as children of any object in the SVG. Here's a simplification of how Particle.appendTo() works:

Particle.prototype.appendTo = function(selector){ var particle = this.types[this.properties.type]; // Sanity check - make sure particle type is valid if (typeof particle == "undefined"){ console.log("Error - particle type not set"); return; } // Add a group to which we can render all of the objects that make up the particle this.node = selector.append("g") .attr("id", this.properties.id) .attr("transform", "translate(" + this.properties.x + "," + this.properties.y + ")"); // Loop through the particle type's defined objects. // Append the object to our parent group and set all of its attributes. for (var object in particle){ var o = this.node.append(object) .attr("class","particle " + this.properties.type); for (var attr in particle[object]){ o.attr(attr, particle[object][attr]); } } return this; };

There's a lot going on here. First of all we do a simple sanity check on the particle type. Once cleared we append a new group node to the parent selector and store it in the Particle's node property. The group node takes on the ID defined in the Particle's properties as well as the x and y position by way of the transform attribute. We can translate, scale, and rotate arbitrarily intricate particles to our heart's content this way, so long as we apply our transformations to the parent group. We can also get a d3 selector for that group node quickly via the node property, and this will become useful when we get into orbits.

Once the parent group is defined we loop through the defined SVG objects for the particle type (e.g. the circle and text objects). Each object kicks off another loop to set its attributes, like the radius on the circle or the line-height on the text. Note that in this simplified version the setting of attributes was collapsed to a single line... oh if only it was that easy! As you can see in the complete class definition at the bottom of this post that block of logic is actually a much less attractive switch statement. For this we can thank minor inconsistencies between the three languages in play (such as the mix of "attribute" and "style" or the use of hyphens in CSS) and leave it at that.

Let's Orbit!

Okay, so particles are neat but once defined they just sit there without further animation. Ideally Particles should be able to orbit other particles or be sent along any arbitrary path.

For simplicity sake, let's call all such paths orbits. An orbit can be defined much like a particle, to start:

var Orbit = function(){ this.node = null; this.properties = { id: '', path: null, stroke: "rgb(128,128,128)", stroke_width: 0.1, stroke_dasharray: "1, 2", tension: 0.5, interpolate: "cardinal-closed", duration: 1000, ease: "out-in", scaleFunction: null, animateFunction: null }; };

This declaration is nothing more than a list of default properties. The scalars among these (everything but scaleFunction and animateFunction ) can and should each have a unified getter/setter method just like in the Particle class. Let's skip ahead to the appendTo() method to render orbits to the SVG:

Orbit.prototype.appendTo = function(selector){ this.node = selector.append("path") .attr("id", this.properties.id) .attr("class", "orbit") .attr("fill", "none") .attr("stroke-width", this.properties.stroke_width) .attr("stroke", this.properties.stroke) .attr("stroke-dasharray", this.properties.stroke_dasharray) .data([this.properties.path]) .attr("d", d3.svg.line().tension(this.properties.tension).interpolate(this.properties.interpolate)); return this; };

This appears simpler than the Particle's appendTo() method because we haven't differentiated orbit types and there's no dynamic creation of SVG objects in addition to the path itself. Just as with Particle we're storing the appended node in the Orbit's node attribute, but there's no parent group here, just a single <path> node.

In the last two lines of the path node's creation, though, some less trivial d3 code pops up. First is the call to the data() method, which takes our path property wrapped in an array. For a comprehensive and high-level overview on what that core d3 method does, see the d3 documention on How Selections Work # Bound to Data. But to simplify it consider this: we want a path along which we can animate a particle, so we'll need to interpolate between points on that path. In order to interpolate d3 needs to parse the path's points as data, so we feed it in through the data() method instead of defining the path's points as an attribute (which would be valid SVG but we couldn't interact with it).

The final line where we set the d attribute defines our interpolation. The tension value (a float bewteen 0 and 1) and the interpolate value (a string) are what change the interpolated path along which our particle will travel from the initial as-the-crow-flies progession (straight lines between each point with a constant speed) to, well, practically anything we want. Just look at some of the weird stuff you can do!

#interp1: Tension: 0.5; Interpolate: cardinal-closed. #interp2: Tension: 0.1; Interpolate: monotone. #interp3: Tension: 0.9; Interpolate: step.

// interp1 var i1 = d3.select("#interp1").append("g").attr("transform","scale(10)"); var i1o = new Orbit().id("i1o").duration(4000) .tension(0.5).interpolate("cardinal-closed") .path([ [2, 10], [10, 2], [18, 10], [10, 18] ]) .appendTo(i1); var i1p = new Particle().type("neutron").id("i1p").scale(1).appendTo(i1); i1o.attachParticle(i1p.node); // interp2 var i2 = d3.select("#interp2").append("g").attr("transform","scale(10)"); var i2o = new Orbit().id("i2o").duration(4000) .tension(0.1).interpolate("monotone") .path([ [2, 10], [10, 2], [18, 10], [10, 18] ]) .appendTo(i2); var i2p = new Particle().type("neutron").id("i2p").scale(1).appendTo(i2); i2o.attachParticle(i2p.node); // interp3 var i3 = d3.select("#interp3").append("g").attr("transform","scale(10)"); var i3o = new Orbit().id("i3o").duration(4000) .tension(0.9).interpolate("step") .path([ [2, 10], [10, 2], [18, 10], [10, 18] ]) .appendTo(i3); var i3p = new Particle().type("neutron").id("i3p").scale(1).appendTo(i3); i3o.attachParticle(i3p.node);

All of the above examples have the same paths and particles and only vary the tension and line interpolation. A full list of d3 line interpolations can be found here and they take some experimentation to really understand. This might also make clear why the defaults baked into the Orbit class's declaration are just so: tenion of 0.5 and interpolation of cardinal-closed will generate smooth loops with just a few points (only four points are needed for any circular or elliptical orbit). Note that tension and interpolation will only change the way the <path> node intersects with the points we define.

Attaching Particles to Orbits and Making Them Go

We can now define a particle and an orbit but we have yet to put the former on the latter to make it dance. To pull this off we borrow heavily from this d3 Point-Along-Path Interpolation. But let's not dive under the hood just yet... let's take a closer look at syntax like that which generated the first interpolation example above:

var g = d3.select("svg").append("g"); var o = new Orbit().duration(4000) .tension(0.5).interpolate("cardinal-closed") .path([ [2, 10], [10, 2], [18, 10], [10, 18] ]) .appendTo(g); var p = new Particle().type("neutron").appendTo(g); o.attachParticle(p.node);

Up until the last line there's nothing new we haven't seen yet. All the magic happens in that Orbit method: attachParticle() . Now let's take a look at that method:

Orbit.prototype.attachParticle = function(particle){ var orbit = this; (function(orbit, particle){ orbit.animateFunction = function(){ particle.transition() .duration(orbit.duration()).ease(orbit.ease()) .attrTween("transform", orbit.transformAlong(orbit.node.node())) .each("end", orbit.animateFunction); }; })(orbit, particle); this.animateFunction(); };

This method takes a particle node as its only argument. This is different from a Particle object in that it needs to be something that a d3 selection returned as we need to attach a transition to it.

The particle node and the Orbit object itself are passed into a closure that defines the orbit's animateFunction . This function is pretty short—define a transition on the particle node, set duration and easing, set tweening ( .attrTween(...) ), and make it call itself again when finished ( .each("end", orbit.animateFunction) ). A closure is necessary here because when the .each() method needs to call animateFunction again the keyword this would no longer be a reference in scope to the Orbit object. Thus we assign this to a new variable ( orbit ) and pass it into the closure so that it's available the same way at all scopes nested within.

Now to break down how the tweening is defined. We use d3's .attrTween() method and pass two parameters: "transform" and a call to one final method on the orbit object, orbit.transformAlong() . To this we pass the d3 selection for the orbit path with the node() method chained off of it to pass the first non-null element of the selection (the path node itself).

Orbit.prototype.transformAlong = function(path){ var l = path.getTotalLength(); var orbit = this; return function(d, i, a) { return function(t) { var p = path.getPointAtLength(t * l); var transform = "translate(" + p.x + "," + p.y + ")"; if (typeof orbit.properties.scaleFunction == "function"){ var s = orbit.properties.scaleFunction(t); transform += " scale(" + s + ")"; } return transform; }; }; };

Here's the final method in the Orbit class. Its purpose is to generate a transform string, or the value to go with the transform attribute on the <path> . Transform strings usually look something like this: "translate(1, 2) scale(3)". The transformAlong() method makes use of two d3 path methods ( getTotalLength() and getPointAtLength() ) to generate x and y values for a point on the path at time t. The value t is a float that ranges from 0 to 1 as we tween along one full length of the path. This means for a looping path the x/y coordinates at t = 0 are the same as those at t = 1.

This method is also where we see the scaleFunction property get used. This is a property that can be set with a chained method much like setting id or tension. It's not a scalar, however. It's a function that generates a scale value (arbitrary float) at time t. I added this because I could, but also because scaling a particle as it moves along a path can provide an illusion of depth.

Hydrogen-1 (Protium) showing off a little depth.

var protium = d3.select("#protium").append("g").attr("transform","scale(10)"); var proton = new Particle().type("proton").x(20).y(5).scale(2.5).appendTo(protium); var orbit = new Orbit().duration(4000) .path([ [20, 8], [2, 5], [20, 2], [38, 5] ]) // Scale the electron from 30x at t=0 to 1x at t=0.5 and back to 30x at t=1 .scaleFunction(function(t){ return 1 + (30 * Math.abs(t - 0.5)); }) .appendTo(protium); var electron = new Particle().type("electron").appendTo(protium); orbit.attachParticle(electron.node);

Background and Going Further

I came up with these classes while working on Nuclides.org, a d3-powered visualization of all elements and nuclides. It's a big project and I've been struggling some with finding the best ways to answer relevant questions about the world of atoms. Data is powerful and can be made beautiful, but without basic diagrams to assist visitors can quickly become lost.

I also have a good idea of where I'll need to go with these classes. Alpha and beta radiation, for example, involve changes in an atom's nucleus. In alpha radiation an alpha particle (two protons and two neutrons, also known as a Helium-4 nucleus) are emitted from the nucleus, and in beta radiation a proton turns into a neutron while emitting an electron. I want to show this with real data, and simplified particle diagrams like this will be crucial to help a broader swath of visitors just get it.

Going further I could see easily adding more particles, even going so far as some day modeling all those in the standard model (I've already got one: the electron). Something I might try a little more immediately, though, might be a class to abstract a nucleus or a mass of particles without orbits. I've worked with force-directed graphs in d3 before and could picture a class that could pack Z protons and N neutrons into a ball that jiggles randomly.

At any rate, soon I should hopefully be wrapping up work on a major branch to rearchitect Nuclides.org in such a way that more readily incorporates diagrams like this as a complement to element and nuclide data. When that branch is merged and deployed the latest and greatest Particle and Orbit classes will be open sourced there.

Full Particle and Orbit Class Declarations

"use strict"; /** Particle - A simple group of shapes and text to depict particles such as protons or neutrons Example usage: var p = new Particle().type("foo").appendTo(parent); */ var Particle = function(){ this.node = null; this.properties = { type: null, id: '', x: 0, y: 0, scale: 1 }; this.types = { proton: { circle: { r: 1, stroke_width: 0.1 }, text: { text: 'p+', x: -0.65, y: 0.4, font_size: 1.3 } }, neutron: { circle: { r: 0.9, stroke_width: 0.1 }, text: { text: 'n', x: -0.4, y: 0.35, font_size: 1.3 } }, electron: { circle: { r: 0.1, stroke_width: 0.02 }, text: { text: 'e-', x: -0.06, y: 0.045, font_size: 0.15 } } }; }; // Type is a required string and must match a type in this.types Particle.prototype.type = function(value){ if (typeof value == "undefined"){ return this.properties.type; } else { if (typeof this.types[value] == "undefined"){ console.log("Error - invalid particle type:" + value); } else { this.properties.type = value.toString(); } return this; } }; Particle.prototype.id = function(value){ if (typeof value == "undefined"){ return this.properties.id; } else { this.properties.id = value.toString(); return this; } }; Particle.prototype.x = function(value){ if (typeof value == "undefined"){ return this.properties.x; } else { this.properties.x = parseFloat(value); return this; } }; Particle.prototype.y = function(value){ if (typeof value == "undefined"){ return this.properties.y; } else { this.properties.y = parseFloat(value); return this; } }; // An arbitrary float to scale the particle's radius and label. // All particles have a radius relative to the proton, which has a radius of 1. Particle.prototype.scale = function(value){ if (typeof value == "undefined"){ return this.properties.scale; } else { this.properties.scale = parseFloat(value); return this; } }; // Render the particle SVG object group as a child of the provided selector Particle.prototype.appendTo = function(selector){ var particle = this.types[this.properties.type]; if (typeof particle == "undefined"){ console.log("Error - particle type not set"); return false; } this.node = selector.append("g").attr("id", this.properties.id) .attr("transform", "translate(" + this.properties.x + "," + this.properties.y + ")"); for (var object in particle){ var o = this.node.append(object).attr("class","particle " + this.properties.type); for (var attr in particle[object]){ var val = particle[object][attr]; var css_attr = attr.replace("_","-"); switch(css_attr){ case 'text': o.text(val); break; case 'font-size': o.style(css_attr, (val * this.properties.scale) + "px"); break; case "stroke-width": case "r": case "x": case "y": val *= this.properties.scale; default: o.attr(css_attr, val); break; } } } return this; }; /** Orbit - A path along which a Particle can be made to travel Example usage: var o = new Orbit().path([ [0, 1], [1, 1], [1, 0], [0, 0] ]).appendTo(parent); var p = new Particle().type("foo").appendTo(parent); o.attachParticle(p.node); */ var Orbit = function(){ this.node = null; this.properties = { id: '', path: 1, stroke: "rgb(128,128,128)", stroke_width: 0.1, stroke_dasharray: "1, 2", tension: 0.5, interpolate: "cardinal-closed", duration: 1000, ease: "out-in", scaleFunction: null, animateFunction: null }; }; Orbit.prototype.id = function(value){ if (typeof value == "undefined"){ return this.properties.id; } else { this.properties.id = value.toString(); return this; } }; // Path should be provided as an array of two-value arrays (e.g. [ [x1, y1], [x2, y2], ... ]) Orbit.prototype.path = function(value){ if (typeof value == "undefined"){ return this.properties.path; } else { this.properties.path = value; return this; } }; Orbit.prototype.stroke = function(value){ if (typeof value == "undefined"){ return this.properties.stroke; } else { this.properties.stroke = value.toString(); return this; } }; Orbit.prototype.stroke_width = function(value){ if (typeof value == "undefined"){ return this.properties.stroke_width; } else { this.properties.stroke_width = parseFloat(value); return this; } }; Orbit.prototype.stroke_dasharray = function(value){ if (typeof value == "undefined"){ return this.properties.stroke_dasharray; } else { this.properties.stroke_dasharray = value.toString(); return this; } }; // Tension is a float between 0 and 1 Orbit.prototype.tension = function(value){ if (typeof value == "undefined"){ return this.properties.tension; } else { this.properties.tension = parseFloat(value); return this; } }; // See d3 documentation on line interpolation: https://github.com/mbostock/d3/wiki/SVG-Shapes#line_interpolate Orbit.prototype.interpolate = function(value){ if (typeof value == "undefined"){ return this.properties.interpolate; } else { this.properties.interpolate = value.toString(); return this; } }; // Time for a particle to travel the full length of the path once, in milliseconds Orbit.prototype.duration = function(value){ if (typeof value == "undefined"){ return this.properties.duration; } else { this.properties.duration = parseInt(value); return this; } }; // See d3 documentation on easing: https://github.com/mbostock/d3/wiki/Transitions#d3_ease Orbit.prototype.ease = function(value){ if (typeof value == "undefined"){ return this.properties.ease; } else { this.properties.ease = value.toString(); return this; } }; // Scale is a function that converts a particle's position along the path (float from 0 to 1) // to scaling transformation on the particle itself (an arbitrary float). // E.g.: new Orbit().scale(function(t){ return 1 + t; }); will scale a particle from 1x to 2x // as it travels along the full length of the path from t = 0 to t = 1. Orbit.prototype.scaleFunction = function(value){ if (typeof value == "undefined"){ return this.properties.scaleFunction; } else if (typeof value == "function") { this.properties.scaleFunction = value; return this; } }; // Render the path SVG object as a child of the provided selector Orbit.prototype.appendTo = function(selector){ this.node = selector.append("path") .attr("id", this.properties.id) .attr("class", "orbit") .attr("fill", "none") .attr("stroke-width", this.properties.stroke_width) .attr("stroke", this.properties.stroke) .attr("stroke-dasharray", this.properties.stroke_dasharray) .data([this.properties.path]) .attr("d", d3.svg.line().tension(this.properties.tension).interpolate(this.properties.interpolate)); return this; }; // Attach an existing particle to the path to animate its motion along the path. // Note: path and particle must already be rendered as SVG objects before this is called. Orbit.prototype.attachParticle = function(particle){ var orbit = this; (function(orbit, particle){ orbit.animateFunction = function(){ particle.transition() .duration(orbit.duration()).ease(orbit.ease()) .attrTween("transform", orbit.transformAlong(orbit.node.node())) .each("end", orbit.animateFunction); }; })(orbit, particle); this.animateFunction(); }; // Private function used to generate transform values for every step in a particle's animation along a path Orbit.prototype.transformAlong = function(path){ var l = path.getTotalLength(); var orbit = this; return function(d, i, a) { return function(t) { var p = path.getPointAtLength(t * l); var transform = "translate(" + p.x + "," + p.y + ")"; if (typeof orbit.properties.scaleFunction == "function"){ var s = orbit.properties.scaleFunction(t); transform += " scale(" + s + ")"; } return transform; }; }; };

--



Christopher Clark

2015-05-01

Copyright © Christopher Clark (Frencil) 2016.