Home > Software design >  Why create arrays of properties in D3 Force-Directed Graph example?
Why create arrays of properties in D3 Force-Directed Graph example?

Time:01-25

I'm adapting Mike Bostock's D3 Force Directed Graph example for a project.

I don't understand why he creates arrays of single properties from the nodes/links then references those arrays later on.

For example:

// this creates an array of node ids like ["id1", "id2", ...]
const N = d3.map(nodes, nodeId).map(intern);
// ...
nodes = d3.map(nodes, (_, i) => ({id: N[i]}));
// ...
const forceLink = d3.forceLink(links).id(({index: i}) => N[i]);

Why not just do:

nodes = d3.map(nodes, (d) => ({id: d.id}));
// ...
const forceLink = d3.forceLink(links).id((d) => d.id);

It's a lot simpler, and the end result seems to be the same. But this is an official example from the creator of D3, so perhaps there's a reason he did it that I'm not understanding?

And actually he has an example where he doesn't create arrays here.

I tried logging the variable values to understand the code, but I still don't understand why he did it that way.

CodePudding user response:

It's a matter of best practices. N is not an array of single properties (you probably meant "array of objects with a single property"), it's an array of primitives. Also, Bostock is using the JavaScript convention of using all caps for constants, like N (for nodes), LS (for link sources), LT (for link targets) etc.

The clue comes just after that, in his comment:

// Replace the input nodes and links with mutable objects for the simulation.

Thus, nodes and links are mutable arrays, which the simulation manipulates (the simulation itself adds several properties to each object), while he keeps a reference to the arrays with the original, immutable values. Finally, notice that objects are mutable, while strings (a primitive) are not.

CodePudding user response:

The example builds a configurable component ForceGraph that is a function which takes two arguments: 1) an object containing the properties nodes and links plus 2) an object containing properties for customizing the ForceGraph component:

function ForceGraph({                                   // <== first parameter, nodes and links
  nodes, // an iterable of node objects (typically [{id}, …])
  links // an iterable of link objects (typically [{source, target}, …])
}, {                                                    // <== second parameter, configuration
  nodeId = d => d.id, // given d in nodes, returns a unique identifier (string)
  /* more config options */
} = {}) {
  // Compute values.
  const N = d3.map(nodes, nodeId).map(intern);
  const LS = d3.map(links, linkSource).map(intern);
  const LT = d3.map(links, linkTarget).map(intern);

The function ForceGraph defines a reusable component which can take data for nodes and links in an arbitrary format. That's why the comment on the nodes line says

an iterable of node objects (typically [{id}, …])

The configuration object's (i.e. the second parameter) first property defines an accessor function used to extract a unique identifier for the nodes:

nodeId = d => d.id, // given d in nodes, returns a unique identifier (string)

That is the reason why there is an itermediate array N containing those identifiers being computed using said accessor function. The array N (as well as all the other helper objects and arrays) is used to normalize the external data structure to the internal representation:

// Replace the input nodes and links with mutable objects for the simulation.
nodes = d3.map(nodes, (_, i) => ({id: N[i]}));
links = d3.map(links, (_, i) => ({source: LS[i], target: LT[i]}));

The intermediate step provides an abstraction of the internal data from the external data. This ensures that the inner workings of ForceGraph can always act on the same pre-defined data structure, no matter what the external data structure might look like.

If, for example, your nodes' data looks like

data = {
  nodes : [{ 
    key: { main: "m", sub: "1" }
  }, {
    key: { main: "m", sub: "2" }
  }, {
    /* ...*/
  }],
  links: { /* ... */
};

You can call ForceGraph passing a custom accessor function that returns unique copound keys for your nodes:

graph = ForceGraph(data, {
  nodeId: d => d.key.main   d.key.sub
})

Thanks to the intermediate transformation of the input data this is all it takes to make the graph work without the need to change any of the inner workings of the component.

  • Related