javascriptd3.js

d3.js multiple relationship visual / linkHorizontal() / tangled tree


I am trying to mimic a visual that depicts multiple relationships by time period, like this (time period = generation):

enter image description here

However, my efforts have not panned out thus far; I'm still getting blank output in the browser. Hard coded data and code in the snippet:

var margins = {top:20, bottom:300, left:30, right:100};

var height = 600;
var width = 900;

var totalWidth = width+margins.left+margins.right;
var totalHeight = height+margins.top+margins.bottom;

var svg = d3.select('body')
.append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight);

var graphGroup = svg.append('g')
.attr('transform', "translate("+margins.left+","+margins.top+")");

  var levels = [
    [{id: 'Chaos'}],
    [
      {id: 'Gaea', parents: ['Chaos']},
      {id: 'Uranus'}
    ],
    [
      {id: 'Oceanus', parents: ['Gaea', 'Uranus']},
      {id: 'Thethys', parents: ['Gaea', 'Uranus']},
      {id: 'Pontus'},
      {id: 'Rhea', parents: ['Gaea', 'Uranus']},
      {id: 'Cronus', parents: ['Gaea', 'Uranus']},
      {id: 'Coeus', parents: ['Gaea', 'Uranus']},
      {id: 'Phoebe', parents: ['Gaea', 'Uranus']},
      {id: 'Crius', parents: ['Gaea', 'Uranus']},
      {id: 'Hyperion', parents: ['Gaea', 'Uranus']},
      {id: 'Iapetus', parents: ['Gaea', 'Uranus']},
      {id: 'Thea', parents: ['Gaea', 'Uranus']},
      {id: 'Themis', parents: ['Gaea', 'Uranus']},
      {id: 'Mnemosyne', parents: ['Gaea', 'Uranus']}
    ],
    [
      {id: 'Doris', parents: ['Oceanus', 'Thethys']},
      {id: 'Neures', parents: ['Pontus', 'Gaea']},
      {id: 'Dionne'},
      {id: 'Demeter', parents: ['Rhea', 'Cronus']},
      {id: 'Hades', parents: ['Rhea', 'Cronus']},
      {id: 'Hera', parents: ['Rhea', 'Cronus']},
      {id: 'Alcmene'},
      {id: 'Zeus', parents: ['Rhea', 'Cronus']},
      {id: 'Eris'},
      {id: 'Leto', parents: ['Coeus', 'Phoebe']},
      {id: 'Amphitrite'},
      {id: 'Medusa'},
      {id: 'Poseidon', parents: ['Rhea', 'Cronus']},
      {id: 'Hestia', parents: ['Rhea', 'Cronus']}
    ],
    [
      {id: 'Thetis', parents: ['Doris', 'Neures']},
      {id: 'Peleus'},
      {id: 'Anchises'},
      {id: 'Adonis'},
      {id: 'Aphrodite', parents: ['Zeus', 'Dionne']},
      {id: 'Persephone', parents: ['Zeus', 'Demeter']},
      {id: 'Ares', parents: ['Zeus', 'Hera']},
      {id: 'Hephaestus', parents: ['Zeus', 'Hera']},
      {id: 'Hebe', parents: ['Zeus', 'Hera']},
      {id: 'Hercules', parents: ['Zeus', 'Alcmene']},
      {id: 'Megara'},
      {id: 'Deianira'},
      {id: 'Eileithya', parents: ['Zeus', 'Hera']},
      {id: 'Ate', parents: ['Zeus', 'Eris']},
      {id: 'Leda'},
      {id: 'Athena', parents: ['Zeus']},
      {id: 'Apollo', parents: ['Zeus', 'Leto']},
      {id: 'Artemis', parents: ['Zeus', 'Leto']},
      {id: 'Triton', parents: ['Poseidon', 'Amphitrite']},
      {id: 'Pegasus', parents: ['Poseidon', 'Medusa']},
      {id: 'Orion', parents: ['Poseidon']},
      {id: 'Polyphemus', parents: ['Poseidon']}
    ],
    [
      {id: 'Deidamia'},
      {id: 'Achilles', parents: ['Peleus', 'Thetis']},
      {id: 'Creusa'},
      {id: 'Aeneas', parents: ['Anchises', 'Aphrodite']},
      {id: 'Lavinia'},
      {id: 'Eros', parents: ['Hephaestus', 'Aphrodite']},
      {id: 'Helen', parents: ['Leda', 'Zeus']},
      {id: 'Menelaus'},
      {id: 'Polydueces', parents: ['Leda', 'Zeus']}
    ],
    [
      {id: 'Andromache'},
      {id: 'Neoptolemus', parents: ['Deidamia', 'Achilles']},
      {id: 'Aeneas(2)', parents: ['Creusa', 'Aeneas']},
      {id: 'Pompilius', parents: ['Creusa', 'Aeneas']},
      {id: 'Iulus', parents: ['Lavinia', 'Aeneas']},
      {id: 'Hermione', parents: ['Helen', 'Menelaus']}
    ]
  ]

  // precompute level depth
  levels.forEach((l,i) => l.forEach(n => n.level = i))

  var nodes = levels.reduce( ((a,x) => a.concat(x)), [] )
  var nodes_index = {}
  nodes.forEach(d => nodes_index[d.id] = d)

  // objectification
  nodes.forEach(d => {
    d.parents = (d.parents === undefined ? [] : d.parents).map(p => nodes_index[p])
  })

  // precompute bundles
  levels.forEach((l, i) => {
    var index = {}
    l.forEach(n => {
      if(n.parents.length == 0) {
        return
      }

      var id = n.parents.map(d => d.id).sort().join('--')
      if (id in index) {
        index[id].parents = index[id].parents.concat(n.parents)
      }
      else {
        index[id] = {id: id, parents: n.parents.slice(), level: i}
      }
      n.bundle = index[id]
    })
    l.bundles = Object.keys(index).map(k => index[k])
    l.bundles.forEach((b, i) => b.i = i)
  })

  var links = []
  nodes.forEach(d => {
    d.parents.forEach(p => links.push({source: d, bundle: d.bundle, target: p}))
  })

  var bundles = levels.reduce( ((a,x) => a.concat(x.bundles)), [] )

  // reverse pointer from parent to bundles
  bundles.forEach(b => b.parents.forEach(p => {
    if(p.bundles_index === undefined) {
      p.bundles_index = {}
    }
    if(!(b.id in p.bundles_index)) {
      p.bundles_index[b.id] = []
    }
    p.bundles_index[b.id].push(b)
  }))

  nodes.forEach(n => {
    if(n.bundles_index !== undefined) {
      n.bundles = Object.keys(n.bundles_index).map(k => n.bundles_index[k])
    }
    else {
      n.bundles_index = {}
      n.bundles = []
    }
    n.bundles.forEach((b, i) => b.i = i)
  })

  links.forEach(l => {
    if(l.bundle.links === undefined) {
      l.bundle.links = []
    }
    l.bundle.links.push(l)
  })

  // layout
  const padding = 8
  const node_height = 22
  const node_width = 70
  const bundle_width = 14
  const level_y_padding = 16
  const metro_d = 4
  const c = 16
  const min_family_height = 16

  nodes.forEach(n => n.height = (Math.max(1, n.bundles.length)-1)*metro_d)

  var x_offset = padding
  var y_offset = padding
  levels.forEach(l => {
    x_offset += l.bundles.length*bundle_width
    y_offset += level_y_padding
    l.forEach((n, i) => {
      n.x = n.level*node_width + x_offset
      n.y = node_height + y_offset + n.height/2

      y_offset += node_height + n.height
    })
  })

  var i = 0
  levels.forEach(l => {
    l.bundles.forEach(b => {
      b.x = b.parents[0].x + node_width + (l.bundles.length-1-b.i)*bundle_width
      b.y = i*node_height
    })
    i += l.length
  })

  links.forEach(l => {
    l.xt = l.target.x
    l.yt = l.target.y + l.target.bundles_index[l.bundle.id].i*metro_d - l.target.bundles.length*metro_d/2 + metro_d/2
    l.xb = l.bundle.x
    l.xs = l.source.x
    l.ys = l.source.y
  })

  // compress vertical space
  var y_negative_offset = 0
  levels.forEach(l => {
    y_negative_offset += -min_family_height + d3.min(l.bundles, b => d3.min(b.links, link => (link.ys-c)-(link.yt+c))) || 0
    l.forEach(n => n.y -= y_negative_offset)
  })

  // very ugly, I know
  links.forEach(l => {
    l.yt = l.target.y + l.target.bundles_index[l.bundle.id].i*metro_d - l.target.bundles.length*metro_d/2 + metro_d/2
    l.ys = l.source.y
    l.c1 = l.source.level-l.target.level > 1 ? node_width+c : c
    l.c2 = c
  })

  const cluster = d3.cluster()
    .size([width, height]);

  const root = d3.hierarchy(links);
  cluster(root);

  var nodeG = svg.selectAll('.node')
      .data(root.links())
      .attr('class','node')
      .enter()
      .append('g');

      nodeG.append("path")
        .attr("class", "link")
        .attr("d", d3.linkHorizontal()
          .x(function(d) { return d.y; })
          .y(function(d) { return d.x; }))
        .style('stroke-width', '3px');
<script src="https://d3js.org/d3.v5.min.js"></script>

As far as I know, all the pieces are in place. I have my data in levels and then have wrangled the necessary hierarchy coordinates using:

  var links = []
  nodes.forEach(d => {
    d.parents.forEach(p => links.push({source: d, bundle: d.bundle, target: p}))
  })

and

  const cluster = d3.cluster()
    .size([width, height]);

  const root = d3.hierarchy(links);
  cluster(root);

  var nodeG = svg.selectAll('.node')
      .data(root.links())
      .attr('class','node')
      .enter()
      .append('g');

From here, I went with d3.linkHorizontal() for my link function:

      nodeG.append("path")
        .attr("class", "link")
        .attr("d", d3.linkHorizontal()
          .x(function(d) { return d.y; })
          .y(function(d) { return d.x; }))
        .style('stroke-width', '3px');

Conceptually, I don't see how including multiple relationships per node changes things. And, absent any errors in the console log, I'm not sure how to troubleshoot further.

Question

What is preventing my visual from rendering as desired (in the picture above)? Would like exact replica if possible.

Edit

Here is the visual on observable if that helps, but can't be viewed as a standalone visual.

https://observablehq.com/@nitaku/tangled-tree-visualization-ii?collection=@nitaku/tangled-trees


Solution

  • Nothing is appended in the svg element except graphGroup. Apparently root.links() return an empty array and nothing is appended in the svg. That is also the reason why you are not getting any errors.

    By creating this array and iterating on it the basic shape that you want to achieve in your tree is implemented if you change also:

    .attr("d", d3.linkHorizontal()
              .x(function(d) { return d.y; })
              .y(function(d) { return d.x; }))
    

    with:

     .attr("d", d3.linkHorizontal()
              .source(d => [d.xs,d.ys] )
              .target(d => [d.xt,d.yt]))
    

    The basic shape of the tree you want to implement can be seen in the below snippet. Try to see if this example could help in styling your tree as desired.

    var margins = {
      top: 20,
      bottom: 300,
      left: 30,
      right: 100
    };
    
    var height = 600;
    var width = 900;
    
    var totalWidth = width + margins.left + margins.right;
    var totalHeight = height + margins.top + margins.bottom;
    
    var svg = d3.select('body')
      .append('svg')
      .attr('width', totalWidth)
      .attr('height', totalHeight);
    
    var graphGroup = svg.append('g')
      .attr('transform', "translate(" + margins.left + "," + margins.top + ")");
    
    var levels = [
      [{
        id: 'Chaos'
      }],
      [{
          id: 'Gaea',
          parents: ['Chaos']
        },
        {
          id: 'Uranus'
        }
      ],
      [{
          id: 'Oceanus',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Thethys',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Pontus'
        },
        {
          id: 'Rhea',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Cronus',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Coeus',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Phoebe',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Crius',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Hyperion',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Iapetus',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Thea',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Themis',
          parents: ['Gaea', 'Uranus']
        },
        {
          id: 'Mnemosyne',
          parents: ['Gaea', 'Uranus']
        }
      ],
      [{
          id: 'Doris',
          parents: ['Oceanus', 'Thethys']
        },
        {
          id: 'Neures',
          parents: ['Pontus', 'Gaea']
        },
        {
          id: 'Dionne'
        },
        {
          id: 'Demeter',
          parents: ['Rhea', 'Cronus']
        },
        {
          id: 'Hades',
          parents: ['Rhea', 'Cronus']
        },
        {
          id: 'Hera',
          parents: ['Rhea', 'Cronus']
        },
        {
          id: 'Alcmene'
        },
        {
          id: 'Zeus',
          parents: ['Rhea', 'Cronus']
        },
        {
          id: 'Eris'
        },
        {
          id: 'Leto',
          parents: ['Coeus', 'Phoebe']
        },
        {
          id: 'Amphitrite'
        },
        {
          id: 'Medusa'
        },
        {
          id: 'Poseidon',
          parents: ['Rhea', 'Cronus']
        },
        {
          id: 'Hestia',
          parents: ['Rhea', 'Cronus']
        }
      ],
      [{
          id: 'Thetis',
          parents: ['Doris', 'Neures']
        },
        {
          id: 'Peleus'
        },
        {
          id: 'Anchises'
        },
        {
          id: 'Adonis'
        },
        {
          id: 'Aphrodite',
          parents: ['Zeus', 'Dionne']
        },
        {
          id: 'Persephone',
          parents: ['Zeus', 'Demeter']
        },
        {
          id: 'Ares',
          parents: ['Zeus', 'Hera']
        },
        {
          id: 'Hephaestus',
          parents: ['Zeus', 'Hera']
        },
        {
          id: 'Hebe',
          parents: ['Zeus', 'Hera']
        },
        {
          id: 'Hercules',
          parents: ['Zeus', 'Alcmene']
        },
        {
          id: 'Megara'
        },
        {
          id: 'Deianira'
        },
        {
          id: 'Eileithya',
          parents: ['Zeus', 'Hera']
        },
        {
          id: 'Ate',
          parents: ['Zeus', 'Eris']
        },
        {
          id: 'Leda'
        },
        {
          id: 'Athena',
          parents: ['Zeus']
        },
        {
          id: 'Apollo',
          parents: ['Zeus', 'Leto']
        },
        {
          id: 'Artemis',
          parents: ['Zeus', 'Leto']
        },
        {
          id: 'Triton',
          parents: ['Poseidon', 'Amphitrite']
        },
        {
          id: 'Pegasus',
          parents: ['Poseidon', 'Medusa']
        },
        {
          id: 'Orion',
          parents: ['Poseidon']
        },
        {
          id: 'Polyphemus',
          parents: ['Poseidon']
        }
      ],
      [{
          id: 'Deidamia'
        },
        {
          id: 'Achilles',
          parents: ['Peleus', 'Thetis']
        },
        {
          id: 'Creusa'
        },
        {
          id: 'Aeneas',
          parents: ['Anchises', 'Aphrodite']
        },
        {
          id: 'Lavinia'
        },
        {
          id: 'Eros',
          parents: ['Hephaestus', 'Aphrodite']
        },
        {
          id: 'Helen',
          parents: ['Leda', 'Zeus']
        },
        {
          id: 'Menelaus'
        },
        {
          id: 'Polydueces',
          parents: ['Leda', 'Zeus']
        }
      ],
      [{
          id: 'Andromache'
        },
        {
          id: 'Neoptolemus',
          parents: ['Deidamia', 'Achilles']
        },
        {
          id: 'Aeneas(2)',
          parents: ['Creusa', 'Aeneas']
        },
        {
          id: 'Pompilius',
          parents: ['Creusa', 'Aeneas']
        },
        {
          id: 'Iulus',
          parents: ['Lavinia', 'Aeneas']
        },
        {
          id: 'Hermione',
          parents: ['Helen', 'Menelaus']
        }
      ]
    ]
    
    // precompute level depth
    levels.forEach((l, i) => l.forEach(n => n.level = i));
    
    var nodes = levels.reduce(((a, x) => a.concat(x)), []);
    var nodes_index = {};
    nodes.forEach(d => nodes_index[d.id] = d);
    
    // objectification
    nodes.forEach(d => {
      d.parents = (d.parents === undefined ? [] : d.parents).map(p => nodes_index[p])
    })
    
    // precompute bundles
    levels.forEach((l, i) => {
      var index = {}
      l.forEach(n => {
        if (n.parents.length == 0) {
          return
        }
    
        var id = n.parents.map(d => d.id).sort().join('--')
        if (id in index) {
          index[id].parents = index[id].parents.concat(n.parents)
        } else {
          index[id] = {
            id: id,
            parents: n.parents.slice(),
            level: i
          }
        }
        n.bundle = index[id]
      })
      l.bundles = Object.keys(index).map(k => index[k])
      l.bundles.forEach((b, i) => b.i = i)
    })
    
    var links = []
    nodes.forEach(d => {
      d.parents.forEach(p => links.push({
        source: d,
        bundle: d.bundle,
        target: p
      }))
    })
    
    var bundles = levels.reduce(((a, x) => a.concat(x.bundles)), [])
    
    // reverse pointer from parent to bundles
    bundles.forEach(b => b.parents.forEach(p => {
      if (p.bundles_index === undefined) {
        p.bundles_index = {}
      }
      if (!(b.id in p.bundles_index)) {
        p.bundles_index[b.id] = []
      }
      p.bundles_index[b.id].push(b)
    }))
    
    nodes.forEach(n => {
      if (n.bundles_index !== undefined) {
        n.bundles = Object.keys(n.bundles_index).map(k => n.bundles_index[k])
      } else {
        n.bundles_index = {}
        n.bundles = []
      }
      n.bundles.forEach((b, i) => b.i = i)
    })
    
    links.forEach(l => {
      if (l.bundle.links === undefined) {
        l.bundle.links = []
      }
      l.bundle.links.push(l)
    })
    
    // layout
    const padding = 8
    const node_height = 22
    const node_width = 70
    const bundle_width = 14
    const level_y_padding = 16
    const metro_d = 4
    const c = 16
    const min_family_height = 16
    
    nodes.forEach(n => n.height = (Math.max(1, n.bundles.length) - 1) * metro_d)
    
    var x_offset = padding
    var y_offset = padding
    levels.forEach(l => {
      x_offset += l.bundles.length * bundle_width
      y_offset += level_y_padding
      l.forEach((n, i) => {
        n.x = n.level * node_width + x_offset
        n.y = node_height + y_offset + n.height / 2
    
        y_offset += node_height + n.height
      })
    })
    
    var i = 0
    levels.forEach(l => {
      l.bundles.forEach(b => {
        b.x = b.parents[0].x + node_width + (l.bundles.length - 1 - b.i) * bundle_width
        b.y = i * node_height
      })
      i += l.length
    })
    
    links.forEach(l => {
      l.xt = l.target.x
      l.yt = l.target.y + l.target.bundles_index[l.bundle.id].i * metro_d - l.target.bundles.length * metro_d / 2 + metro_d / 2
      l.xb = l.bundle.x
      l.xs = l.source.x
      l.ys = l.source.y
    })
    
    // compress vertical space
    var y_negative_offset = 0
    levels.forEach(l => {
      y_negative_offset += -min_family_height + d3.min(l.bundles, b => d3.min(b.links, link => (link.ys - c) - (link.yt + c))) || 0
      l.forEach(n => n.y -= y_negative_offset)
    })
    
    // very ugly, I know
    links.forEach(l => {
      l.yt = l.target.y + l.target.bundles_index[l.bundle.id].i * metro_d - l.target.bundles.length * metro_d / 2 + metro_d / 2
      l.ys = l.source.y
      l.c1 = l.source.level - l.target.level > 1 ? node_width + c : c
      l.c2 = c
    })
    
    const cluster = d3.cluster()
      .size([width, height]);
    
    const root = d3.hierarchy(links);
    cluster(root);
    let oValues = Object.values(root)[0];
    let linkks = oValues.map(x => x.bundle.links);
    
    linkks.forEach((linkk) => {
     let nodeG1 = svg.append("g")
        .selectAll("circle")
        .data(linkk)
        .join("circle")
        .attr("cx", d => d.target.x)
        .attr("cy", d => d.target.y)
        .attr("fill", "none")
        .attr("stroke", (d) => {
          return '#' + Math.floor(16777215 * Math.sin(3 * Math.PI / (5 * (parseInt(d.target.level) + 1)))).toString(16);
        })
        .attr("r", 6);
      let nodeG11 = svg.append("g")
        .selectAll("circle")
        .data(linkk)
        .join("circle")
        .attr("cx", d => d.source.x)
        .attr("cy", d => d.source.y)
        .attr("fill", "none")
        .attr("stroke", (d) => {
          return '#' + Math.floor(16777215 * Math.sin(3 * Math.PI / (5 * (parseInt(d.source.level) + 1)))).toString(16);
        })
        .attr("r", 6);
    
    
      let nodeG2 = svg.append("g")
        .attr("font-family", "sans-serif")
        .attr("font-size", 14)
        .selectAll("text")
        .data(linkk)
        .join("text")
        .attr("class", "text")
        .attr("x", d => d.target.x + padding)
        .attr("y", d => d.target.y)
        .text(d => d.target.id )
        .attr("fill", (d) => {
          return '#' + Math.floor(16777215 * Math.sin(3 * Math.PI / (5 * (parseInt(d.target.level) + 2)))).toString(16);
        });
     
     let nodeG22 = svg.append("g")
        .attr("font-family", "sans-serif")
        .attr("font-size", 14)
        .selectAll("text")
        .data(linkk)
        .join("text")
        .attr("class", "text")
        .attr("x", d => d.source.x + padding)
        .attr("y", d => d.source.y)
        .text(d => d.source.id )
        .attr("fill", (d) => {
          return '#' + Math.floor(16777215 * Math.sin(3 * Math.PI / (5 * (parseInt(d.source.level) + 1)))).toString(16);
        });
     
      let nodeG = svg.append('g')
        .attr('class', 'node')
        .selectAll("path")
        .data(linkk)
        .join('path')
        .attr("class", "link")
        .attr("d", d3.linkHorizontal()
          .source(d => [d.xs, d.ys])
          .target(d => [d.xt, d.yt]))
        .attr("fill", "none")
        .attr("stroke-opacity", 0.325)
        .attr("stroke-width", 0.75)
        .attr("stroke", (d) => {
          return '#' + Math.floor(16777215 * Math.sin(3 * Math.PI / (4 * parseInt(d.source.level)))).toString(16);
        });
    });
    path {
      display: block;
      z-index: 0;
    }
    
    text,
    circle {
      display: block;
      z-index: 1000;
    }
    <script src="https://d3js.org/d3.v5.min.js"></script>