9

I'm having problems generating a good looking family tree with Javascript.

Requirements:

  • Each child should be connected to two parents in the tree, not one as in some graphs
  • I've like spouses to be next to each other in the tree (same vertical position)
  • I'd like to organize the nodes by generation vertically, so that you can see people born in the same decade at a glance.
  • One person can have multiple spouses over time, and children with each of them
  • Parents and children can be added freely in the tree, so not just "trace the lineage from one person upwards"

What I've tried that has come closest to this:

  1. Cytoscape JS with Dagre as layout engine, and curve-style: taxi edges enabled. Family tree

    (Chart with random data. Solid lines are parent-child relationsships, dashed lines are spouses)

    The problem is that spouses are not aligned with each other. Dagre has historically supported "rank" as a parameter for nodes, which means that you could force some nodes to be at a specific height (think of it as a "generation" if you will). Unfortunately, it doesn't work any longer, and the responsible developer does not work on the project any longer. This would nicely solve my problem.

Other things I've tried but failed:

  1. Downgrading dagre to an older version that has support for rank?

    Haven't gotten rank to work with ANY version of dagre.

  2. D3 with dagre-d3

    Same problem as above, as dagre-d3 is a modified version of dagre, which means it does not have support for ranking in generations.

  3. yFiles family tree demo looks great, but is commercial. The cost for my purposes (would like anyone to set up their own family tree) is 26.000 USD (!?!) for a single developer license. Obviously not acceptable.

    yFiles family tree

My question

Is it possible to get align the nodes in my cytoscape/dagre graph vertically as I've described above?

If not, i'm open to trying other libraries and other layout algorithms.

I'm looking for a working example that looks similar to the yFiles solution, but using open source tools.

Emil Stenström
  • 13,329
  • 8
  • 53
  • 75
  • Have you seen this answer https://stackoverflow.com/a/31367673/2627160 and example http://jsfiddle.net/cyril123/0vbtvoon/22/? This seems to get reasonably close to what you're looking for, with D3? – Matt Saunders May 01 '20 at 10:17
  • @MattSaunders That solutions does not support multiple spouses as far as I understand. – Emil Stenström May 01 '20 at 10:46
  • OK - other than multiple spouses, are your requirements covered by this example? I might not have time myself :) but might help you get an answer more quickly if this can be used as a starting point! – Matt Saunders May 01 '20 at 10:52
  • It's very low-level, and I'd rather see something that uses cytoscape or dagre-d3, but sure, anything is better than nothing :) – Emil Stenström May 01 '20 at 11:01
  • I would say that it sounds like you're trying to support elements of both hierarchical and non-hierarchical (https://en.wikipedia.org/wiki/Graph_(abstract_data_type)) data structures... E.g. if my mother remarries my wife's brother and they have a child, technically my brother in law would need to exist on two 'rows'. And it would be valid for the child to be both on the same row and one row below me in the tree. – Matt Saunders May 01 '20 at 11:34
  • I'm sure it's possible, e.g. you could say "family members will always be placed on the highest possible row" but as you stray further from a strict hierarchy (i.e. the lineage of one descendant), you're going to be more vulnerable to edge cases. – Matt Saunders May 01 '20 at 11:35
  • As a suggestion, I'd think about how you might structure the data as a first step. E.g. is a nested array of some kind viable, or not? Once that's sorted I think it will be clearer how it could be visualised graphically – Matt Saunders May 01 '20 at 11:36
  • Today I've stored the data in three SQL tables, people, partners, couples. The data structure you suggest below therefore is perfect for me! – Emil Stenström May 02 '20 at 15:58

2 Answers2

6

Before you get too deep into my answer :) you might want to check out WebCola, which I came across while researching constrained force directed graphs:

JavaScript constraint based layout for high-quality graph visualization and exploration using D3.js and other web-based graphics libraries.

It allows you to specify x and y dimensional constraints as I've done on the y dimension in my example below. I've not used it myself, but looks like a really good fit for your requirements. And it works with CytoScape so you may be able to build on what you've already done...

Applying dimensional constraints to a force-directed graph:

As you're not dealing with a strict hierarchy (e.g. you're not starting with one descendant and working your way up), one approach would be to use a D3 Force Directed Graph with a node to represent each family member. This will provide added flexibility, compared with a linear hierarchy.

The generational layout you're looking for could then be achieved by constraining the nodes to fixed points on the y axis.

Here is a proof of concept:

  • Three generations of family members
  • Multiple spouses are represented by Alice and Bob / Bob and Carol
  • David is a child of Alice and Bob
  • James is a child of Bob and Carol
  • Node generation (or y coordinate) calculated by assignGeneration based on linked child, partner and parent nodes
  • Node X coordinate is handled by d3, which I think will be more robust than trying to manually assign each node a position on the x axis
  • Basic styling:
    • Partner links are coral
    • Child links are light blue
    • Sibling links are light green

Hopefully there is enough here for you to decide whether this is a viable approach. Setting up presentational vertical / horizontal links between parents and children should be fairly straightforward but might take a bit of experimentation.

Adjustments (depending on the volume of data and node relationships etc.) will likely need to be applied to simulation - again, some experimentation will be needed to generate an optimal layout. More information about the different forces available here.

<!DOCTYPE html>
<html>

<head>
  <style>
svg {
  border: 1px solid gray;
}

.partner_link {
  stroke: lightcoral;
}

.child_link {
  stroke: lightskyblue;
}

.sibling_link {
  stroke: lightseagreen;
}
  </style>
</head>

<body>
  <script src="https://d3js.org/d3.v5.min.js"></script>
  <script type="text/javascript">

var nodeData = [{
  id: 1,
  name: 'Alice',
  partners: [2],
  children: [4]
}, {
  id: 2,
  name: 'Bob',
  partners: [1, 3],
  children: [4,10]
}, {
  id: 3,
  name: 'Carol',
  partners: [2],
  children: [10]
}, {
  id: 4,
  name: 'David',
  partners: [7],
  children: [8]
}, {
  id: 5,
  name: 'Emily',
  partners: [6],
  children: [7, 9]
}, {
  id: 6,
  name: 'Fred',
  partners: [5],
  children: [7, 9]
}, {
  id: 7,
  name: 'Grace',
  partners: [4],
  children: [8]
}, {
  id: 8,
  name: 'Harry',
  partners: null,
  children: null
}, {
  id: 9,
  name: 'Imogen',
  partners: null,
  children: null
}, {
  id: 10,
  name: 'James',
  partners: null,
  children: null
}];

var linkData = [];

nodeData.forEach((node, index) => {
  if (node.partners) {
    node.partners.forEach(partnerID => {
      linkData.push({ source: node, target: nodeData.find(partnerNode => partnerNode.id === partnerID), relationship: 'Partner' });
    })
  }
  if (node.children) {
    node.children.forEach(childID => {
      const childNode = nodeData.find(childNode => childNode.id === childID);
      if (node.children.length > 1) {
        childNode.siblings = node.children.slice(0, node.children.indexOf(childNode.id)).concat(node.children.slice(node.children.indexOf(childNode.id) + 1, node.children.length));
        childNode.siblings.forEach(siblingID => {
          linkData.push({ source: childNode, target: nodeData.find(siblingNode => siblingNode.id === siblingID), relationship: 'Sibling' });
        })
      }
      linkData.push({ source: node, target: childNode, relationship: 'Child' });
    })
  }
});

linkData.map(d => Object.create(d));

assignGeneration(nodeData, nodeData, 0);

var w = 500,
  h = 500;

var svg = d3.select("body")
  .append("svg")
  .attr("width", w)
  .attr("height", h);

var color = d3.scaleOrdinal(d3.schemeCategory10);

var rowScale = d3.scalePoint()
  .domain(dataRange(nodeData, 'generation'))
  .range([0, h - 50])
  .padding(0.5);

var simulation = d3.forceSimulation(nodeData)
  .force('link', d3.forceLink().links(linkData).distance(50).strength(1))
  .force("y", d3.forceY(function (d) {
    return rowScale(d.generation)
  }))
  .force("charge", d3.forceManyBody().strength(-300).distanceMin(60).distanceMax(120))
  .force("center", d3.forceCenter(w / 2, h / 2));

var links = svg.append("g")
  .attr("stroke", "#999")
  .attr("stroke-opacity", 0.8)
  .selectAll("line")
  .data(linkData)
  .join("line")
  .attr("stroke-width", 1)
  .attr("class", d => {
    return d.relationship.toLowerCase() + '_link';
  });;

var nodes = svg.append("g")
  .attr("class", "nodes")
  .selectAll("g")
  .data(nodeData)
  .enter().append("g")

var circles = nodes.append("circle")
  .attr("r", 5)
  .attr("fill", function (d) {
    return color(d.generation)
  });

var nodeLabels = nodes.append("text")
  .text(function (d) {
    return d.name;
  }).attr('x', 12)
  .attr('y', 20);

var linkLabels = links.append("text")
  .text(function (d) {
    return d.relationship;
  }).attr('x', 12)
  .attr('y', 20);

/*
// Y Axis - useful for testing:
var yAxis = d3.axisLeft(rowScale)(svg.append("g").attr("transform", "translate(30,0)"));
*/

simulation.on("tick", function () {
  links
    .attr("x1", d => {
      return d.source.x;
    })
    .attr("y1", d => {
      return rowScale(d.source.generation);
    })
    .attr("x2", d => {
      return d.target.x;
    })
    .attr("y2", d => {
      return rowScale(d.target.generation);
    });
  nodes.attr("transform", function (d) {
    return "translate(" + d.x + "," + rowScale(d.generation) + ")";
  })
});

function dataRange(records, field) {
  var min = d3.min(records.map(record => parseInt(record[field], 10)));
  var max = d3.max(records.map(record => parseInt(record[field], 10)));
  return d3.range(min, max + 1);
};

function assignGeneration(nodes, generationNodes, generationCount) {
  const childNodes = [];
  generationNodes.forEach(function (node) {
    if (node.children) {
      // Node has children
      node.generation = generationCount + 1;
      node.children.forEach(childID => {
        if (!childNodes.find(childNode => childNode.id === childID)) {
          childNodes.push(generationNodes.find(childNode => childNode.id === childID));
        }
      })
    } else {
      if (node.partners) {
        node.partners.forEach(partnerID => {
          if (generationNodes.find(partnerNode => partnerNode.id === partnerID && partnerNode.children)) {
            // Node has partner with children
            node.generation = generationCount + 1;
          }
        })
      } else {
        // Use generation of parent + 1
        const parent = nodes.find(parentNode => parentNode.children && parentNode.children.indexOf(node.id) !== -1);
        node.generation = parent.generation + 1;
      }
    }
  });
  if (childNodes.length > 0) {
    return assignGeneration(nodes, childNodes, generationCount += 1);
  } else {
    nodes.filter(node => !node.generation).forEach(function (node) {
      node.generation = generationCount + 1;
    });
    return nodes;
  }
}

  </script>
</body>

</html>
Matt Saunders
  • 3,538
  • 2
  • 22
  • 30
  • Wow! This looks really promising. Generating the data in the structure your suggest is easy, much easier than creating a tree. – Emil Stenström May 02 '20 at 15:56
  • 1
    Thanks for the useful feedback @EmilStenström! I have an approach in mind for processing the data but will need to put a little time into working it out. Will update my answer as soon as I’ve made some progress. – Matt Saunders May 02 '20 at 16:39
  • 1
    You’re doing all the work here, it’s me who should thank you! Let me know if I can do anything else! – Emil Stenström May 02 '20 at 20:55
  • Have added some updates @EmilStenström - let me know how you get on! WebCola (which I found about 20 mins ago) might be your best option, but the principle is basically the same – Matt Saunders May 04 '20 at 11:07
  • Thanks again! I think this is enough for me to work with. I've awarded you the full bounty! :) I've briefly looked at WebCola, but couldn't figure out how to add constraints. I'll read up on your solution, and I'm sure I'll figure something out. – Emil Stenström May 04 '20 at 21:00
  • Thanks Emil - that’s given my rep a nice boost! Happy to help - best of luck with the project – Matt Saunders May 04 '20 at 22:14
  • I'm afraid I haven't gotten this approach to work. When you extend the dots to bigger rectangles you get all kinds of overlap between rectangles on the same generation. I've experimented with extending margins where possible, added a collision force, but I think things are getting too complicated for d3 force layout I think. I'll remove the accepted answer on this (don't worry, this doesn't affect the bounty! :)), and look into webcola instead. – Emil Stenström May 09 '20 at 06:00
  • Totally understand Emil - I think getting webcola working is the way to go. Otherwise you're just going to end up rebuilding a lot of it yourself! Did you see this, for preventing overlapping elements? https://ialab.it.monash.edu/webcola/examples/smallnonoverlappinggraph.html – Matt Saunders May 09 '20 at 21:57
  • Also might be worth a look: https://observablehq.com/@mbostock/hello-cola – Matt Saunders May 11 '20 at 13:21
1

Update 2023: I actually gave up on this approach and ended up going with graphviz instead. It's tricky to work with, but it works, and it's what I've ended up with in the end.

Emil Stenström
  • 13,329
  • 8
  • 53
  • 75