Decoupling d3 tree logic to the caller

I have been successful in creating a Tree visualization, but now I want to decouple the additional code I needed to create the visualization. I have made modifications based on Tidy tree component / D3 | Observable. My hope is to just clean up the code by having the caller of the Tree handle the now-coupled code, and possibly to see if I can transition my data and most of the logic over to Oberservable’s Tree Plot. However, I don’t even know if I am on the right track.

Here is a working Visualization

To see what the modifications do to the visualization, enable Indicators and select a date.

But the modifications are as followed:

  • Node Fill Color
  • Node Radius
  • Node Text Font Weight
  • Node Text X Offset

I know the last 2 modifications can’t be moved over to Observable’s Tree Plot. At least not yet.

This allows me to call the code like the following. Notice I don’t have any logic for the above 4 items in the calling code.

Tree(find("XBB.1.5"), {
  width: ...,
  heightCoefficent: ...,
  highlightWeeks: ...
})

Here is my unsuccessful decoupling attempt

Tree is what I am trying to decouple.

Tree(find("XBB.1.5"), {
  width: ...,
  heightCoefficent: ...,
  // highlightWeeks: ... This will be handled elsewhere in the d3 code

  label: (d) => d.data.lineage.pango,
  nodeRadius: (d) => nodeRadius(numberOfWeeks(d.data.data)),
  nodeTextWeight: (d) => nodeTextWeight(numberOfWeeks(d.data.data)),
})

I moved the label out and that works find. However, I also tried to work on 2 other separate parts of my modified code. One part seemed to work (node radius), but doing something very similar to another part failed (node text weight). So I figure I am doing something wrong already.

I tried to make it as simple as possible, so I got rid of the ternary logic, which I will have to add back in.

I have to admit I’m not entirely clear about your goal. Can you explain what you mean by “moving the label out”? Are you trying to make the rendering of the tree composable, or do you want to avoid having to use data selector callbacks?

For Tree in SARS-CoV-2 Phylogenetic Tree / Christopher Rucinski | Observable, I have the following code, which coupled Tree to this particular data structure. If I want to use Tree with different data, I have to modify the Tree function, rather than modify the caller

function Tree(
  data,
  {
    label = (d) => d.data.lineage.pango, // given a node d, returns the display name
    ...
  } = {}
) {
  ...
}
Tree(find("XBB.1.5"), {
  ...
})

This allowed me to call Tree without needing to specify the label. This kind of coupling is also present in other aspects of the above Tree.

I was able to fix that in Community Question: Decouple Modified Tree Logic / Christopher Rucinski | Observable

function Tree(
  data,
  {
    label,
    ...
  } = {}
) {
  ...
}
Tree(find("XBB.1.5"), {
  label: (d) => d.data.lineage.pango,
  ...
})

Here I tell the Tree function what I want the label to be via option argument.

Decoupling the label was the easy part. Decoupling the d3 logic seems a lot more challenging.

Right, but this is still just a data selector that doesn’t handle the actual rendering, so I wasn’t sure why you consider this to be “decoupled”. And since it’s already present in the original Tree widget it’s not clear to me what changes you had to make.

Instead of modifying the Tree function itself you can also wrap it in another function that provides your custom defaults (or any abstraction that you’d need):

MyTree = (data, options = {}) => Tree(data, {
  label = (d) => d.data.lineage.pango,
  ...options
})

I think it might help if you described what parts of the tree you want to be customizable, and in what way.

Here is the code that I want to decouple and have the calling code handle. All the d3.scaleDiverging() is an implementation detail. The next time I use this code, I might not want to use a diverging scale. See the comments in the code for what modifications I made and what I want to do.

I also would like to not have to touch the inside of the Tree function anymore. So that means that the code I added to Tree over the last few weeks (compared to the original Tree from Tidy tree component / D3 | Observable), I want to move out of the function.

Setting the domain and range for each attribute (node radius, node color, node text weight, node text offset, etc…) shouldn’t be done inside of Tree. I only added it in there because I am new and was trying to figure out how this all works.

My goal is to minimalize the Tree function to more closely match the original but is a more plug-and-playable Tree which can easily be modified by the caller code. I believe I could even possibly use Oberservable’s Tree Plot for the node fill color and node radius

Current Coupled Tree Code

function Tree(
  data,
  {
    ...
    weeks = 1
  } = {}
) {

  ...

  // I moved these 2 function out
  function divergingDomainOn(numberOfWeeks) {
    return [
      1,
      numberOfWeeks + 1,
      Span("2021-11-24", new Date(Date.now())).numberWeeks
    ];
  }

  let spannedWeeks = function (node) {
    return Span(node.data.lineage.designation_date, new Date(Date.now()))
      .numberWeeks;
  };

  // I also moved these d3 code snippets out. 
  let fill_color = d3
    .scaleDiverging()
    .domain(divergingDomainOn(weeks))
    .range(["#D52322", "lightgray", "black"]);
  // .interpolator(d3.interpolateRdYlGn);

  let node_radius = d3
    .scaleDiverging()
    .domain(divergingDomainOn(weeks))
    .range([7, 3, 2]);
  // .interpolator(d3.interpolateRdYlGn);

  let font_weight = d3
    .scaleDiverging()
    .domain(divergingDomainOn(weeks))
    .range([1000, 500, 400]);

  // For the below, I don't want to do arrow functions here. 
  // I just want .attr("fill", nodeFill) and .attr("r", nodeRadius). 
  // nodeFill and nodeRadius would be options passed into Tree()
  node
    .append("circle")
    .attr("fill", (d) => (weeks == 0 ? fill : fill_color(spannedWeeks(d.data))))
    .attr("r", (d) => (weeks == 0 ? r : node_radius(spannedWeeks(d.data))));

  if (title != null) node.append("title").text((d) => title(d.data, d));

  // Same thing for x offset and font-weight.
  if (L)
    node
      .append("text")
      .attr("dy", "0.32em")
      .attr("x", weeks == 0 ? 4 : 10)
      .attr("text-anchor", "start")
      .attr("paint-order", "stroke")
      .attr("stroke", halo)
      .attr("stroke-width", haloWidth)
      .attr("font-weight", (d) =>
        weeks == 0 ? 400 : font_weight(spannedWeeks(d.data))
      )
      .text((d, i) => L[i]);

  return svg.node();
}

Expected Decoupled Tree Code

Here is a more minimalized version of Tree where the implementation detail is handled by the caller; not Tree.

function Tree(
  data,
  {
    ...
    nodeRadius = 3,
    nodeFill = "#999",
    nodeFontWeight = 400,
    nodeTextOffsetX = 4
  } = {}
) {

  ...
  node
    .append("circle")
    .attr("fill", nodeFill)
    .attr("r", nodeRadius);

  if (title != null) node.append("title").text((d) => title(d.data, d));

  if (L)
    node
      .append("text")
      .attr("dy", "0.32em")
      .attr("x", nodeTextOffsetX)
      .attr("text-anchor", "start")
      .attr("paint-order", "stroke")
      .attr("stroke", halo)
      .attr("stroke-width", haloWidth)
      .attr("font-weight", nodeFontWeight)
      .text((d, i) => L[i]);

  return svg.node();
}

But I want the caller code to do something like the following.

Tree(find("XBB.1.5"), {
  width: ...,
  heightCoefficent: ...,

  label: (d) => d.data.lineage.pango,
  nodeRadius: (d) => nodeRadius(numberOfWeeks(d.data.lineage.designation_date)),
  nodeFontWeight: (d) => nodeTextWeight(numberOfWeeks(d.data.lineage.designation_date)),
  nodeFill: ...,
  nodeTextOffsetX: ...
})

// d3.scale code
...

Reply

This isn’t really what I am trying to accomplish. I don’t want to bury the coupled code.

I should not have said this, as it led my follow-up reply to you to be directed toward this. Please don’t focus on the label. That should carry no weight in my question. Sorry

… and where are you stuck? What’s preventing you from moving your scale related code out of your Tree function and just passing in the individual attribute callbacks?

I thought it would be fairly straightforward, but that doesn’t seem to be the case at all. First of all, I don’t understand why I need to use d.data.data and why that works for one and not the other.

Here is what I have so far

Tree(find("XBB.1.5"), {
  width: width,
  // Works with d.data
  label: (d) => d.data.lineage.pango,
  // Doesn't work with d.data. Requires d.data.data for some reason, but works.
  nodeRadius: (d) =>
    nodeRadius(numberOfWeeks(d.data.data.lineage.designation_date)),
   // Doesn't work at all no matter what I do; no matter how similar it is to above
   nodeTextWeight: (d) =>
    nodeTextWeight(numberOfWeeks(d.data.data.lineage.designation_date)),
})

// These are in separate cells
highlightWeeks = 1
scale = d3.scaleDiverging().domain(divergingDomainOn(highlightWeeks))
nodeRadius = scale.range([7, 4, 2])
nodeTextWeight = scale.range([1000, 500, 400])

Because the implementation already passes d.data to the callback:

  const L = label == null ? null : descendants.map((d) => label(d.data, d));
  // ...
  node
    .append("circle")
    .attr("fill", (d) =>
      highlightWeeks == 0 ? fill : fill_color(numberOfWeeks(d.data))
    )
    .attr("r", nodeRadius);
  // ...
  if (title != null) node.append("title").text((d) => title(d.data, d));

OK, so based on what you said, it tried passing in d.data to each function. That cleared up the double-data confusion. I had one more issue from above that I did end up fixing (see below), but that led me to realize that I could only pass in a function as the options and not values like "#aaa". So I am now doing the following…

  node
    .append("circle")
    .attr("fill", (d) => apply(nodeFill, d.data))
    .attr("r", (d) => apply(nodeRadius, d.data));
  function apply(func, data) {
    return typeof func == "function" ? func(data) : func;
  }

Now I can pass in either a d3 scale function or a constant value.

As for the other issue. I needed to scale.copy()

scale = d3.scaleDiverging().domain(divergingDomainOn(since(modifier.date)))

nodeFill = scale.copy().range(["#D52322", "lightgray", "black"])

nodeRadius = scale.copy().range([7, 4, 2])

...

With that, now my code works with this refactoring. Now my calling code does all the work.

Tree(find(searchterm ? searchterm.pango : "omicron"), {
  width: modifier.width * width,
  heightCoefficent: modifier.height,
  fontFamily: "monospace",
  fontSize: 11,

  label: (d) => d.data.lineage.pango,
  nodeRadius: (d) => nodeRadius(since(d.data.lineage.designation_date)),
  nodeTextWeight: (d) => nodeTextWeight(since(d.data.lineage.designation_date)),
  nodeFill: (d) => nodeFill(since(d.data.lineage.designation_date)),
  nodeTextOffsetX: (d) =>
    nodeTextOffsetX(since(d.data.lineage.designation_date))
})

And from this point, I can possibly do some abstractions as you pointed out. But I am curious if you have any pointers or concerns with the current approach before I start abstracting. I do notice a lot of “duplicate” code for the function arguments. Not sure if this can be reduced or if it even should be reduced if it can be.

Duplicate code is just a lack of abstractions. :slight_smile: However I don’t think that this is something that your Tree function needs to handle.

In your calling code modifier is an external dependency, and any change to it will cause the current cell to reevaluate. You can therefore change

  nodeRadius: (d) =>
    modifier.indicatorEnabled
      ? nodeRadius(since(d.data.lineage.designation_date))
      : 3,

to

  nodeRadius: modifier.indicatorEnabled
      ? (d) => nodeRadius(since(d.data.lineage.designation_date))
      : 3,

which at least to me already feels concise enough to maybe not warrant another helper.

Still, by identifying the common API we can introduce the helper

dateIndicatorAttr = (scale, defaultValue) => modifier.indicatorEnabled
  ? (d) => scale(since(d.data.lineage.designation_date))
  : defaultValue

so that we end up with

  nodeRadius: dateIndicatorAttr(nodeRadius, 3),
  nodeFontWeight: dateIndicatorAttr(nodeFontWeight, 400),
  nodeFill: dateIndicatorAttr(nodeFill, "#aaa"),
  nodeTextOffsetX: dateIndicatorAttr(nodeTextOffsetX, 6)

in the calling code. This could be condensed further to only take a static range as argument, like

dateIndicatorScale = {
  const scale = d3.scaleDiverging().domain(divergingDomainOn(since(modifier.date)));
  const accessor = (scale) => (d) => scale(since(d.data.lineage.designation_date));
  return modifier.indicatorEnabled
    ? (defaultValue, range) => accessor(scale.copy().range(range))
    : (defaultValue, range) => () => defaultValue;
}

and

  nodeRadius: dateIndicatorScale(3, [7, 4, 2]),
  nodeFontWeight: dateIndicatorScale(400, [1000, 500, 400]),
  nodeFill: dateIndicatorScale("#aaa", ["#D52322", "lightgray", "gray"]),
  nodeTextOffsetX: dateIndicatorScale(6, [10, 5, 4])

The benefit of this approach is that it chooses the abstraction in a way that allows you to iterate faster when testing different parameters for your scales.


If you want even more flexibility you could also move some of the render code outside of your Tree function by utilizing d3-selection’s selection.each and expose the render callbacks as options.

1 Like

This answer is well beyond what I was expecting but I am extremely happy you did put in that effort to go through the process.

I will be reviewing it this week and trying to understand a few parts here and there. JavaScript is quite a language. The abstractions you developed are amazing. Hopefully I can understand what is happening well enough to transfer this knowledge over to other areas!

1 Like