-
Notifications
You must be signed in to change notification settings - Fork 1
D3 Best Practices
This document describes D3 concepts and developing best practices,
D3 works with arrays of data and DOM elements. It performs "data binding" by associating the data array with the DOM element array, letting you specify how that the data transforms a visual aspect of its associated DOM element(s). of the DOM elements. If you change the data, D3 will apply the transform.
-
The basic operations are selecting groups of DOM elements, binding the data array to them, and then transforming their appearance. D3 then uses a hidden update loop to handle additions, deletions, and updates to the data.
-
The basic data structure is the D3.Selection class, which represents a group of DOM elements. Basic D3 programming is managing various kinds of Selections under different operational contexts.
-
A nuance of D3.Selection is that Selection.select() and D3.select() work differently. This also applies to Selection.selectAll() and D3.selectAll(). Generally, you use D3.select/selectAll for the initial capture of DOM elements, and then Selection.select/selectAll to manage selection sets.
D3 is difficult to understand because it emphasizes brevity in expression over being explicit about what is happening. You have to memorize the D3 commands and their undeclared side effects. Conceptually D3 is simple, but the D3 documentation does not make any of this easy to discern. This is perhaps why most D3 documentation and tutorials treat D3 programming like learning "magic recipes" to be repeated rather than tech the principles that make it work.
So far, I think there are several keys to understanding how D3 works:
-
It is the D3.Selection class, not the D3 class, that provides the structure of D3 programming.
-
Each D3.Selection method returns one of three kinds of selection object: a NEW selection object, a MODIFIED NEW selection object, or the UNCHANGED CURRENT selection object. Since D3 examples typically use chaining calls, knowing what each Selection method actually returns is critically important. The convention D3 uses to overcome this is its indentation rule of "2 spaces for methods that return new selections, 4 spaces for methods that return the current selections". You still need to KNOW which methods do what.
-
Writing D3 code requires a mindset akin to writing self-modifying code. The DOM element structure is actively modified, expanded, and added-to by DOM transformation methods like Selection.append() and Selection.insert(). This modifies the DOM and the current selection. Because the various Selection methods return different Selection types made from different starting Selection sets, this quickly becomes dizzying as you trace through the code and try to remember what it does.
-
D3 uses a behind-the-scenes animation loop that scans the data array for additions and removals, which are provided as a Selection object through
enter()
andexit()
. These are hooks used for additional DOM element setup and cleanup. The update loop is implicit in D3 version 4, and is established when you first create a Selection and bind data to it. There is also a hybrid update calledmerge()
that is used when adding new elements AND updating them immediately.
// D3
SelNew select (selector) // return first match in root doc
SelNew selectAll (selector) // return all matches in root doc
// Selection
SelModNew select (subSelector) // return first match in current sel
SelModNew selectAll (subSelector) // return all match in current sel
// selection modifiers
SelModNew filter (match) // return matching elements selection
SelCurrent data (value, key) // key is identity function. value can also be function
datum (value) // get or set data without computing join (?)
enter () // return selection set of new data nodes
exit () // return selection set of removed data nodes
merge (selection$$1) // apply both update/add (to removed a duplication)
SelModNew append (name)
SelModNew insert (name, before)
SelModNew remove ()
SelModNew order ()
SelModNew sort (compare)
SelCurrent call (f,args) // invoke func once per element with passed vars
SelCurrent each (f) // execute arbitrary code for each
nodes ()
node ()
size ()
empty ()
SelCurrent attr (name, value)
SelCurrent style (name, value, priority)
SelCurrent property (name, value)
SelCurrent classed (name, value) // assign/unassign class
SelCurrent text (value)
SelCurrent html (value)
SelCurrent transition (name)
SelCurrent raise ()
SelCurrent lower ()
clone (deep)
on (typename, value, capture)
dispatch (type, params)
interrupt (name)
let svg = d3.select(rootElement).append('svg');
svg
.attr('width', "100%") // overrride m_width so SVG is wide
.attr('height',m_height)
.on("click", ( e, event ) => {
console.log('clicked');
})
let zoomWrapper = svg.append('g').attr("class","zoomer");
let sim = d3.forceSimulation();
// initialize forces
sim
.force("link", d3.forceLink())
.force("charge", d3.forceManyBody())
.force("collide", d3.forceCollide())
.force("center", d3.forceCenter())
.force("forceX", d3.forceX())
.force("forceY", d3.forceY())
.on("tick", this._Tick)
// update forces
sim
.force("link", d3.forceLink()
.id((d) => {return d.id})
.distance( (d)=>{return m_forceProperties.link.distance * (1/d.size) } )
.iterations(m_forceProperties.link.iterations))
.force("charge", d3.forceManyBody()
.strength( (d)=>{return d.size/6 * m_forceProperties.charge.strength * m_forceProperties.charge.enabled} )
.distanceMin(m_forceProperties.charge.distanceMin)
.distanceMax(m_forceProperties.charge.distanceMax))
.force("collide", d3.forceCollide()
.strength(m_forceProperties.collide.strength * m_forceProperties.collide.enabled)
.radius((d) => {return d.size/m_forceProperties.collide.radius;})
.iterations(m_forceProperties.collide.iterations))
.force("center", d3.forceCenter()
.x(m_width * m_forceProperties.center.x)
.y(m_height * m_forceProperties.center.y))
.force("forceX", d3.forceX()
.strength(m_forceProperties.forceX.strength * m_forceProperties.forceX.enabled)
.x(m_width * m_forceProperties.forceX.x))
.force("forceY", d3.forceY()
.strength(m_forceProperties.forceY.strength * m_forceProperties.forceY.enabled)
.y(m_height * m_forceProperties.forceY.y))
// start simulation (or restart it rather)
this.simulation.alpha(1).restart()
let nodeElements = zoomWrapper.selectAll(".node")
.data(this.data.nodes, (d) => { return d.id });
// fn returns the calculated key for the data object
let linkElements = zoomWrapper.selectAll(".edge")
.data(this.data.edges, (d) => { return d.id });
// fn returns the calculated key for the data object
let elementG = nodeElements.enter()
.append("g")
.classed('node',true);
elementG
.call(d3.drag()
.on("start", (d) => { this._Dragstarted(d, this) })
.on("drag", this._Dragged)
.on("end", (d) => { this._Dragended(d, this) }))
.on("click", (d) => {
d3.event.stopPropagation();
});
// enter node: also append 'circle' element of a calculated size
elementG
.append("circle")
.attr("r",
(d) => {
let count = 1
this.data.edges.map( (l) => { l.source == d.id || l.target == d.id ? count++ : 0 } )
d.weight = count
// save the calculated size
d.size = count
return this.defaultSize * d.weight
})
.attr("fill", (d) => { return d.color ? d.color : this.defaultColor; });
// enter node: also append 'text' element
elementG
.append("text")
.classed('noselect', true)
.attr("font-size", 10)
.attr("dx", 8)
.attr("dy", ".15em")
.text((d) => { return d.label });
// enter node: also append a 'title' tag
elementG
.append("title") // node tooltip
.text((d) => { return d.label; });
// UPDATE circles in each node for all nodes
nodeElements.merge(nodeElements)
.selectAll("circle")
.attr("stroke", (d) => { if (d.selected) return d.selected; })
.attr("stroke-width", (d) => { if (d.selected) return '5px'; })
.attr("r",
(d) => {
let count = 1
this.data.edges.map( (l)=>{ l.source.id == d.id || l.target.id == d.id ? count++ : 0 } )
d.weight = count
return this.defaultSize * d.weight
});
// UPDATE text in each node for all nodes
// (technically this could have proceeded from the previous operation,
// but this makes it a little easier to findthe text-specific code as
// a block
nodeElements.merge(nodeElements)
.selectAll("text")
.attr("color", (d) => { if (d.selected) return d.selected; })
.attr("font-weight", (d) => { if (d.selected) return 'bold'; })
.text((d) => { return d.label }); // in case text is updated
// TELL D3 what to do when a data node goes away
nodeElements.exit().remove()
// NOW TELL D3 HOW TO HANDLE NEW EDGE DATA
// .insert will add an svg `line` before the objects classed `.node`
linkElements.enter()
.insert("line",".node")
.classed('edge', true)
.on("click", (d) => {
if (DBG) console.log('clicked on',d.label,d.id)
this.edgeClickFn( d ) })
linkElements.merge(linkElements)
.classed("selected", (d) => { return d.selected })
linkElements.exit().remove()
// UPDATE ANIMATED SIMULATION
// this is a plugin
this.simulation.nodes(this.data.nodes)
this.simulation.force("link").links(this.data.edges)