Skip to content

D3 Best Practices

Flynn Duniho edited this page May 29, 2024 · 3 revisions

This document describes D3 concepts and developing best practices,

The D3 Model

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.

Understanding D3

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() and exit(). 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 called merge() that is used when adding new elements AND updating them immediately.

D3 and D3.Selection Methods (WIP)

// 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)

Annoted Example (from d3-simplenetgraph)

(1) Create an SVG Canvas

  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');
    })

(2)

    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)


Clone this wiki locally