Genogram Children Positioning Problem

Issue: GoJS Genogram Children positioning issues

Description:

We are experiencing some issues with the GoJS Genogram with parent - child relationships,
We have adapted slightly the sample Gojs v2.3.17 sample , the bulk of which would be bindings for colour or adding labels to lines etc

Hoping that your team can provide suggestions Aas far as i know the company has bought a licence in the past also.

Problem:

Children positioning slides to the left in certain scenarios

Examples
These are examples that suit the social care setting of the project I’m contracted on.


Issues arise however with children of relationships

The children for some reason slide off to the left id prefer if they were balanced as occurs in the single mother two children example above.

Node Snippet

  public nodeSetup(showRelationshipLabel: boolean): go.Map<string, go.Node> {
    const categories = [
      { Text: 'Male', shape: 'Square', color: 'white' },
      { Text: 'Female', shape: 'Circle', color: 'white' },
      { Text: 'Unknown', shape: 'Triangle', color: 'white' },
    ];

    let nodeMap = new go.Map<string, go.Node>();

    //build node templates based on array data above
    categories.forEach((element) => {
      nodeMap.add(
        element.Text,
        new go.Node(
          'Vertical', //orientation
          {
            locationSpot: go.Spot.Center,
            locationObjectName: 'ICON',
            contextMenu: this.buildContextMenuForNode(),
            toolTip: this.buildTooltipForNode(),
          }
        ).add(
          new go.Panel('Spot', { name: 'ICON' })
            .add(
              this.buildNodeBaseShape(element)
                .bind(
                  //the line outlining the node shape
                  this.bindStrokeColorToHousehold()
                )
                .bind(
                  //reduce width for unborn persons
                  this.bindShapeSizeForUnbornPersons()
                )
                .bind(
                  //reduce height for unborn persons
                  new go.Binding('height', '', (data: TCMGenogramNodeDataDto) => {
                    if (data.isUnborn) {
                      return GENO_UNBORN_NODE_SIZE;
                    }
                    return GENO_NODE_SIZE;
                  })
                )
            )
            .add(this.getXMarkShapeForDeceasedNode().bind(this.bindXStrokeColorForDeceasedStatus()))
            .add(this.buildCasePersonShapeForNode(element))
            .add(this.buildNameLabelForNode())
            .add(this.buildRelLabelForNode(showRelationshipLabel))
        )
      );
    });

    //overide the default node template for this diagram so that hidden labels
    //used as anchors for lines drawn to children of relationships are not shown
    nodeMap.add('', new go.Node('Auto'));
    return nodeMap;
  }

Link Template Snippet

  private getParentLinkTemplate(showRelationshipLabel: boolean): go.Link {
    return new go.Link({
      routing: go.Link.Orthogonal,
      corner: 5,
      selectable: true,
      selectionAdornmentTemplate: this.linkSelectionAdornmentTemplate,
      layerName: 'Background',
      toolTip: this.buildTooltipChildLink(),
    })
      .add(
        new go.Shape({ strokeWidth: GENO_DEFAULT_STROKE_WIDTH })
          .bind(
            //the '' empty parameter here is binding to the base object (link) which is then typed by the ChildLink Class
            new go.Binding('stroke', '', (childLink: GenogramChildLink) => {
              switch (childLink.parentalRelation) {
                case GenogramParentalRelationshipStatus.Bio:
                  return GENO_DEFAULT_STROKE_COLOUR;
                case GenogramParentalRelationshipStatus.Adopted:
                  return GENO_ADOPTED_STROKE_COLOUR;
                case GenogramParentalRelationshipStatus.Foster:
                  return GENO_FOSTER_STROKE_COLOUR;
                default:
                  return null; //default to black
              }
            })
          )
          .bind(
            new go.Binding('strokeDashArray', '', (childLink: GenogramChildLink) => {
              switch (childLink.parentalRelation) {
                case GenogramParentalRelationshipStatus.Bio:
                  return null; //null = solid line no dash
                case GenogramParentalRelationshipStatus.Adopted:
                  return GENO_DEFAULT_STROKE_DASH_ARRAY;
                case GenogramParentalRelationshipStatus.Foster:
                  return GENO_DEFAULT_STROKE_DASH_ARRAY;
                default:
                  return null;
              }
            })
          )
      )
      .add(
        new go.TextBlock({
          margin: 2,
          stroke: GENO_CHILD_LINK_LBL_COLOUR,
          segmentOrientation: go.Link.OrientUpright,
          segmentOffset: new go.Point(0, -10),
        })
          .bind(
            new go.Binding('text', '', (link: GenogramChildLink) => {
              return link.relationLabel;
            })
          )
          .bind(
            new go.Binding('visible', '', (link: GenogramChildLink) => {
              return showRelationshipLabel;
            })
          )
      );
  }

Environment:

Angular version: v13

Gojs Npm Package versions

"gojs": "^2.3.17",
"gojs-angular": "2.0.4",
  • Browser: Edge Browser Version 134.0.3124.72
  • Operating System: Windows
  • Additional Information: Child link snippet
private setupChildLink(net: go.LayeredDigraphNetwork, link: go.Link) {
    const parent = net.findVertex(link.fromNode); // should be a label node
    const child = net.findVertex(link.toNode);
    if (child !== null) {
      // link both vertexes
      net.linkVertexes(parent, child, link);
    } else {
      // linking to a married descendant
      link.toNode.linksConnected.each((l) => {
        if (!l.isLabeledLink) return; // if it has no label node, it's a parent-child link

        // found the Marriage Link, now get its label Node
        const mlab = l.labelNodes.first();
        // parent-child link should connect with the label node,
        // so the LayoutEdge should connect with the LayoutVertex representing the label node
        const mlabvert = net.findVertex(mlab);
        if (mlabvert !== null) {
          net.linkVertexes(parent, mlabvert, link);
        }
      });
    }
  }

Have you modified the GenogramLayout at all from the 2.3.17 version that you are using? When I interactively modify the graph in that sample, Genogram, so that it has several cases of one and two child families, the layout produces:


I think this looks good and meets your expectations.

So the question is why it’s so different in your app. Maybe you are using an old version of GenogramLayout?

Hi Walter,

Thanks for the reply!
The core changes I can see in the layout class revolve around for multiple link types to be a part of the marriages, so they stay together.( married , divorced , separated templates)
Going back to the sample code as is it improves the simple scenarios

Next steps are to handle a more complex scenario.
I have taken the Genogram layout class as is code as is from the link and left the templates and setup parents function with its condition for adding a link between a single node if both parents are not known or if the relationship between parents is unknown.
And get a slight improvement

But the end goal of the project would be able to render scenarios such as these as close to this output as possible

Hmmm, this is a bit unusual – some designs have the children connected to just one parent, and some designs (like the Genogram sample) have the children connected to the particular marriage from which they were issued, but I think this is the first one that I’ve heard where you want both at the same time in the same family. Is that because in your example Jiminy Cricket’s mother is unknown? Normally in a genogram one would represent that with a separate mother. I suppose GenogramLayout could be extended to handle that case, but I suspect it would be complicated.

Yeah there could be ,

If we were to take the approach that if we were to know both parents always (say a place holder node or something) I think we’ll be stuck still.

Here we have a half brother in the mix on one side, everything looks clear enough ,

If we add another half brother on the mothers side,

things go a bit haywire the parents are flipping over and the marriages/ex marriages look a bit unclear,

When ideally wed get something closer to this

Is there a scenario like this that has been solved (maybe on non active relationships dont flip the spouses) or any other suggestion ?

I’ll look into this. I know that in the past many customers have always wanted the male partner to be on the left side, for some reason. But I don’t think that is enforced in the current implementation if it would increase the number of crossings.

By Current implementation do you mean the latest sample GoJS/samples/genogram.html at v3.0.20 · NorthwoodsSoftware/GoJS · GitHub ?

Looks very similar to the version were able to use with the current project state with extra considerations around child birth order and twins ?

the commit nodes doesnt call

lablink.ensureBounds();

but that doesnt seem to have much of an effect .
setting the mate/marriage link template to avoids nodes
gives the below but still have a child link crossing …

The preference for keeping males on the left side but not when it would increase link crossing applies only to the links to the parents, not to children.

I’m not sure why it’s not re-ordering the children to reduce those link crossing. This will take time to investigate.

Hi Walter,

Any update on the above or timeline wise how long would it be before something like this would be looked into?

Thanks

I have already spent several hours on this, but I don’t yet have a working solution.

Try this replacement GenogramLayout:

class GenogramLayout extends go.LayeredDigraphLayout {
  constructor(init) {
    super();
    this.initializeOption = go.LayeredDigraphInit.DepthFirstIn;
    this.spouseSpacing = 30;  // minimum space between spouses
    this.isRouting = false;
    this._sortedNodes = new go.Map();
    if (init) Object.assign(this, init);
  }

  makeNetwork(coll) {
    // generate LayoutEdges for each parent-child Link
    const net = this.createNetwork();
    if (coll instanceof go.Diagram) {
      this.add(net, coll.nodes, true);
      this.add(net, coll.links, true);
    } else if (coll instanceof go.Group) {
      this.add(net, coll.memberParts, false);
    } else if (coll.iterator) {
      this.add(net, coll.iterator, false);
    }
    return net;
  }

  // internal method for creating LayeredDigraphNetwork where husband/wife pairs are represented
  // by a single LayeredDigraphVertex corresponding to the label Node on the "Mate" Link
  add(net, coll, nonmemberonly) {
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    const multiSpousePeople = new go.Set();
    // consider all Nodes in the given collection
    const it = coll.iterator;
    while (it.next()) {
      const node = it.value;
      if (!(node instanceof go.Node) || !node.data) continue;
      if (!node.isLayoutPositioned || !node.isVisible()) continue;
      if (nonmemberonly && node.containingGroup !== null) continue;
      // if it's an unmarried Node, or if it's a Link Label Node, create a LayoutVertex for it
      if (node.isLinkLabel) {
        // get "Mate" Link
        const link = node.labeledLink;
        if (link.category === "Mate") {
          const spouseA = link.fromNode;
          const spouseB = link.toNode;
          // create vertex representing both husband and wife
          const vertex = net.addNode(node);
          // now define the vertex size to be big enough to hold both spouses
          if (horiz) {
            vertex.height = spouseA.actualBounds.height + this.spouseSpacing + spouseB.actualBounds.height;
            vertex.width = Math.max(spouseA.actualBounds.width, spouseB.actualBounds.width);
            vertex.focus = new go.Point(vertex.width / 2, spouseA.actualBounds.height + this.spouseSpacing / 2);
          } else {
            vertex.width = spouseA.actualBounds.width + this.spouseSpacing + spouseB.actualBounds.width;
            vertex.height = Math.max(spouseA.actualBounds.height, spouseB.actualBounds.height);
            vertex.focus = new go.Point(spouseA.actualBounds.width + this.spouseSpacing / 2, vertex.height / 2);
          }
        }
      } else {
        // don't add a vertex for any married person!
        // instead, code above adds label node for "Mate" link
        // assume a "Mate" Link has a label Node
        let mates = this.countMates(node);
        if (mates === 0) {
          net.addNode(node);
        } else if (mates > 1) {
          multiSpousePeople.add(node);
        }
      }
    }
    // now do all Links
    it.reset();
    while (it.next()) {
      const link = it.value;
      if (!(link instanceof go.Link)) continue;
      if (!link.isLayoutPositioned || !link.isVisible()) continue;
      if (nonmemberonly && link.containingGroup !== null) continue;
      // if it's a parent-child link, add a LayoutEdge for it
      if (link.category === "" && link.data) {
        const parent = net.findVertex(link.fromNode);  // should be a label node
        const child = net.findVertex(link.toNode);
        if (child !== null) {  // an unmarried child
          net.linkVertexes(parent, child, link);
        } else {  // a married child
          link.toNode.linksConnected.each(l => {
            if (l.category !== "Mate" || !l.data) return;  // if it has no label node, it's a parent-child link
            // found the Mate Link, now get its label Node
            const mlab = l.labelNodes.first();
            // parent-child link should connect with the label node,
            // so the LayoutEdge should connect with the LayoutVertex representing the label node
            const mlabvert = net.findVertex(mlab);
            if (mlabvert !== null) {
              net.linkVertexes(parent, mlabvert, link);
            }
          });
        }
      }
    }

    while (multiSpousePeople.count > 0) {
      // find all collections of people that are indirectly married to each other
      const node = multiSpousePeople.first();
      const cohort = new go.Set();
      this.extendCohort(cohort, node);
      const sorted = cohort.toArray();
      sorted.sort((a, b) => this.countMates(b) - this.countMates(a));
      const start = sorted[0];
      const map = new go.Map();
      this.walkMates(start, false, 1000000000, 500000000, map);
      sorted.sort((a, b) => map.get(a) - map.get(b));
      sorted.forEach(n => this._sortedNodes.set(n, sorted));
      const verts = [];
      const seen = new go.Set();
      for (let i = 0; i < sorted.length-1; i++) {
        const n = sorted[i];
        n.linksConnected.each(l => {
          if (l.category === "Mate") {
            const lab = l.labelNodes.first();
            if (lab) {
              const v = net.findVertex(lab);
              if (v && !seen.has(v)) {
                verts.push(v);
                seen.add(v);
              }
            }
          }
        })
      }
      // then encourage them all to be the same generation by connecting them all with a common vertex
      const dummyvert = net.createVertex();
      net.addVertex(dummyvert);
      for (let i = 0; i < verts.length; i++) {
        const v = verts[i];
        net.linkVertexes(dummyvert, v, null);
        // add pairings to try to keep the desired order
        if (i > 0) {
          const w = verts[i-1];
          const dummy = net.createVertex();
          net.addVertex(dummy);
          net.linkVertexes(dummy, w, null);
          net.linkVertexes(dummy, v, null);
          net.linkVertexes(dummy, w, null);
          net.linkVertexes(dummy, v, null);
        }
      }
      // done with these people, now see if there are any other multiple-married people
      multiSpousePeople.removeAll(cohort);
    }
  }

  // collect all of the people indirectly married with a person
  extendCohort(coll, node) {
    if (coll.has(node)) return;
    coll.add(node);
    node.linksConnected.each(l => {
      if (l.category === "Mate") {  // if it's a "Mate" link, continue with both spouses
        this.extendCohort(coll, l.fromNode);
        this.extendCohort(coll, l.toNode);
      }
    });
  }

  // how many Mate relationships does this person have?
  countMates(node) {
    let count = 0;
    node.linksConnected.each(l => {
      if (l.category === "Mate") count++;
    });
    return count;
  }

  walkMates(node, side, val, level, map) {
    if (map.has(node)) return;
    map.set(node, val);
    const count = this.countMates(node);
    level /= 2;
    let idx = 0;
    node.linksConnected.each(l => {
      if (l.category === "Mate") {
        const other = l.getOtherNode(node);
        if (map.has(other)) return;
        idx++;
        const newside = (idx <= count/2) ? side : !side;
        this.walkMates(other, newside, val + (newside ? level : -level), level, map);
      }
    });
  }

  assignLayers() {
    super.assignLayers();
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    // for every vertex, record the maximum vertex width or height for the vertex's layer
    const maxsizes = [];
    this.network.vertexes.each(v => {
      const lay = v.layer;
      let max = maxsizes[lay];
      if (max === undefined) max = 0;
      const sz = (horiz ? v.width : v.height);
      if (sz > max) maxsizes[lay] = sz;
    });
    // now make sure every vertex has the maximum width or height according to which layer it is in,
    // and aligned on the left (if horizontal) or the top (if vertical)
    this.network.vertexes.each(v => {
      const lay = v.layer;
      const max = maxsizes[lay];
      if (horiz) {
        v.focus = new go.Point(0, v.height / 2);
        v.width = max;
      } else {
        v.focus = new go.Point(v.width / 2, 0);
        v.height = max;
      }
    });
    // from now on, the LayeredDigraphLayout will think that the Node is bigger than it really is
    // (other than the ones that are the widest or tallest in their respective layer).
  }

  initializeIndices() {
    super.initializeIndices();
    const vertical = this.direction === 90 || this.direction === 270;
    this.network.edges.each(e => {
      if (e.fromVertex.node && e.fromVertex.node.isLinkLabel) {
        e.portFromPos = vertical ? e.fromVertex.focusX : e.fromVertex.focusY;
      }
      if (e.toVertex.node && e.toVertex.node.isLinkLabel) {
        e.portToPos = vertical ? e.toVertex.focusX : e.toVertex.focusY;
      }
    });

    // get all vertexes for each layer
    var layers = [];  // Array of Arrays of LayeredDigraphVertexes
    this.network.vertexes.each(v => {
      var lay = v.layer;
      if (layers[lay] === undefined) {
        layers[lay] = [v];
      } else {
        layers[lay].push(v);
      }
    });

    // Order the children so that twins/triplets are more likely to be together.
    // now sort them in each layer how you like
    layers.forEach(a => {
      a.sort((v, w) => {
        const vbirth = this.findMultipleBirth(v);
        const wbirth = this.findMultipleBirth(w);
        if (vbirth < wbirth) return -1;
        if (vbirth > wbirth) return 1;
        return 0;
      });
      a.forEach((v, i) => v.index = i);
    });
  }

  // get the birth order for a person; assume zero if there is no data.multiple property value
  findMultipleBirth(v) {
    const node = v.node;
    if (node && node.data) {
      if (node.category === "MateLabel") {
        const link = node.labeledLink;
        if (link) {
          const fn = link.fromNode;
          if (fn && fn.data && fn.data.multiple !== undefined) return fn.data.multiple;
          const tn = link.toNode;
          if (tn && tn.data && tn.data.multiple !== undefined) return tn.data.multiple;
        }
      } else {
        if (node.data.multiple !== undefined) return node.data.multiple;
      }
    }
    return 0;
  }

  commitNodes() {
    super.commitNodes();
    // position regular nodes
    this.network.vertexes.each(v => {
      if (v.node !== null && !v.node.isLinkLabel) {
        v.node.position = new go.Point(v.x, v.y);
      }
    });
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    // position the spouses of each "Mate" vertex
    this.network.vertexes.each(v => {
      if (v.node === null) return;
      if (!v.node.isLinkLabel) return;
      const labnode = v.node;
      const lablink = labnode.labeledLink;
      // In case the spouses are not actually moved, we need to have the "Mate" link
      // position the label node, because LayoutVertex.commit() was called above on these vertexes.
      // Alternatively we could override LayoutVetex.commit to be a no-op for label node vertexes.
      lablink.invalidateRoute();
      let spouseA = lablink.fromNode;
      let spouseB = lablink.toNode;
      if (spouseA.opacity > 0 && spouseB.opacity > 0) {
        // maybe swap if multiple mates are on the other side
        const labA = this.findOtherMateLinkLabelNode(spouseA, lablink);
        const labB = this.findOtherMateLinkLabelNode(spouseB, lablink);
        if (labA) {
          const vA = this.network.findVertex(labA);
          if (vA && vA.x > v.x) {
            const temp = spouseA; spouseA = spouseB; spouseB = temp;
          }
        } else if (labB) {
          const vB = this.network.findVertex(labB);
          if (vB && vB.x < v.x) {
            const temp = spouseA; spouseA = spouseB; spouseB = temp;
          }
        }
        spouseA.moveTo(v.x, v.y);
        if (horiz) {
          spouseB.moveTo(v.x, v.y + spouseA.actualBounds.height + this.spouseSpacing);
        } else {
          spouseB.moveTo(v.x + spouseA.actualBounds.width + this.spouseSpacing, v.y);
        }
      } else if (spouseA.opacity === 0) {
        const pos = horiz
          ? new go.Point(v.x, v.centerY - spouseB.actualBounds.height / 2)
          : new go.Point(v.centerX - spouseB.actualBounds.width / 2, v.y);
        spouseB.move(pos);
        if (horiz) pos.y++; else pos.x++;
        spouseA.move(pos);
      } else if (spouseB.opacity === 0) {
        const pos = horiz
          ? new go.Point(v.x, v.centerY - spouseA.actualBounds.height / 2)
          : new go.Point(v.centerX - spouseA.actualBounds.width / 2, v.y);
        spouseA.move(pos);
        if (horiz) pos.y++; else pos.x++;
        spouseB.move(pos);
      }
    });
    this._sortedNodes.clear();
  }

  findOtherMateLinkLabelNode(node, link) {
    const it = node.linksConnected;
    while (it.next()) {
      const l = it.value;
      if (l.category === "Mate" && l !== link) return l.labelNodes.first();
    }
    return null;
  }
}  // end GenogramLayout class

Hi Walter thanks for the reply!,
Before :

After:
Definitely helps! but still have some crossing when there is a step sibling on both sides.
Ilya here is fine but for some reason banjo is being positioned awkwardly causing a crossing ?

If we strip it back further and make Mary cake is not married we can see both step siblings are close to being positioned correctly but the line (orthogonal) is joining on one side (Ilya line shouldnt touch the line of Barry) and not the other.
(layer spacing set to 100 here to give more room also)

Any other suggestions ? or tweaks that could be made ?

Could you please provide the model so I can reproduce?

Node array

[
  {
    "key": 9138,
    "name": "Livia Pollo",
    "lastName": "Pollo",
    "gender": "Female",
    "parents": [],
    "enduringRelationships": [
      { "personId": 9140, "status": 0 },
      { "personId": 9153, "status": 2 }
    ]
  },
  {
    "key": 9153,
    "name": "Mick Pollo",
    "lastName": "Pollo",
    "gender": "Male",
    "parents": [],
    "enduringRelationships": [{ "personId": 9138, "status": 2 }]
  },
  {
    "key": 9137,
    "name": "BArry BAloon",
    "lastName": "BAloon",
    "gender": "Male",
    "parents": [
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9140,
    "name": "Ger CAKE",
    "lastName": "CAKE",
    "gender": "Male",
    "parents": [],
    "enduringRelationships": [
      { "personId": 9138, "status": 0 },
      { "personId": 9150, "status": 2 }
    ]
  },
  {
    "key": 9141,
    "name": "Carmella Soprano",
    "lastName": "Soprano",
    "gender": "Male",
    "parents": [
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9144,
    "name": "Jiminy cricket",
    "lastName": "cricket",
    "gender": "Male",
    "parents": [
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9150,
    "name": "Ex Wife xu",
    "lastName": "xu",
    "gender": "Female",
    "parents": [],
    "enduringRelationships": [{ "personId": 9140, "status": 2 }]
  },
  {
    "key": 9151,
    "name": "Ilya Xu",
    "lastName": "Xu",
    "gender": "Male",
    "parents": [
      { "personId": 9150, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9152,
    "name": "banjo Kazooie",
    "lastName": "Kazooie",
    "gender": "Female",
    "parents": [
      { "personId": 9153, "relationship": 0, "relationshipLabel": "Daughter" },
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Daughter" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9136,
    "name": "MAry Cake",
    "lastName": "Cake",
    "gender": "Female",
    "parents": [
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Daughter" },
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Daughter" }
    ],
    "enduringRelationships": []
  }
]

I see. That actually doesn’t have anything to do with the GenogramLayout, but with how the “Mate” Link is implemented.

Here’s a custom MateLink class, inheriting from Link, that is used by the “Mate” Link template.

I also include the code to convert your data schema to a GoJS model schema, which is automatically performed whenever the GraphLinksModel.linkDataArray is empty.

To simplify the code, I removed support for twins, whether identical or not.

<!DOCTYPE html>
<html>
<body>
  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <script id="code">

// A custom layout that shows the two families related to a person's parents
class GenogramLayout extends go.LayeredDigraphLayout {
  constructor(init) {
    super();
    this.initializeOption = go.LayeredDigraphInit.DepthFirstIn;
    this.spouseSpacing = 30;  // minimum space between spouses
    this.isRouting = false;
    this._sortedNodes = new go.Map();
    if (init) Object.assign(this, init);
  }

  makeNetwork(coll) {
    // generate LayoutEdges for each parent-child Link
    const net = this.createNetwork();
    if (coll instanceof go.Diagram) {
      this.add(net, coll.nodes, true);
      this.add(net, coll.links, true);
    } else if (coll instanceof go.Group) {
      this.add(net, coll.memberParts, false);
    } else if (coll.iterator) {
      this.add(net, coll.iterator, false);
    }
    return net;
  }

  // internal method for creating LayeredDigraphNetwork where husband/wife pairs are represented
  // by a single LayeredDigraphVertex corresponding to the label Node on the "Mate" Link
  add(net, coll, nonmemberonly) {
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    const multiSpousePeople = new go.Set();
    // consider all Nodes in the given collection
    const it = coll.iterator;
    while (it.next()) {
      const node = it.value;
      if (!(node instanceof go.Node) || !node.data) continue;
      if (!node.isLayoutPositioned || !node.isVisible()) continue;
      if (nonmemberonly && node.containingGroup !== null) continue;
      // if it's an unmarried Node, or if it's a Link Label Node, create a LayoutVertex for it
      if (node.isLinkLabel) {
        // get "Mate" Link
        const link = node.labeledLink;
        if (link.category === "Mate") {
          const spouseA = link.fromNode;
          const spouseB = link.toNode;
          // create vertex representing both husband and wife
          const vertex = net.addNode(node);
          // now define the vertex size to be big enough to hold both spouses
          if (horiz) {
            vertex.height = spouseA.actualBounds.height + this.spouseSpacing + spouseB.actualBounds.height;
            vertex.width = Math.max(spouseA.actualBounds.width, spouseB.actualBounds.width);
            vertex.focus = new go.Point(vertex.width / 2, spouseA.actualBounds.height + this.spouseSpacing / 2);
          } else {
            vertex.width = spouseA.actualBounds.width + this.spouseSpacing + spouseB.actualBounds.width;
            vertex.height = Math.max(spouseA.actualBounds.height, spouseB.actualBounds.height);
            vertex.focus = new go.Point(spouseA.actualBounds.width + this.spouseSpacing / 2, vertex.height / 2);
          }
        }
      } else {
        // don't add a vertex for any married person!
        // instead, code above adds label node for "Mate" link
        // assume a "Mate" Link has a label Node
        let mates = this.countMates(node);
        if (mates === 0) {
          net.addNode(node);
        } else if (mates > 1) {
          multiSpousePeople.add(node);
        }
      }
    }
    // now do all Links
    it.reset();
    while (it.next()) {
      const link = it.value;
      if (!(link instanceof go.Link)) continue;
      if (!link.isLayoutPositioned || !link.isVisible()) continue;
      if (nonmemberonly && link.containingGroup !== null) continue;
      // if it's a parent-child link, add a LayoutEdge for it
      if (link.category === "" && link.data) {
        const parent = net.findVertex(link.fromNode);  // should be a label node
        const child = net.findVertex(link.toNode);
        if (child !== null) {  // an unmarried child
          net.linkVertexes(parent, child, link);
        } else {  // a married child
          link.toNode.linksConnected.each(l => {
            if (l.category !== "Mate" || !l.data) return;  // if it has no label node, it's a parent-child link
            // found the Mate Link, now get its label Node
            const mlab = l.labelNodes.first();
            // parent-child link should connect with the label node,
            // so the LayoutEdge should connect with the LayoutVertex representing the label node
            const mlabvert = net.findVertex(mlab);
            if (mlabvert !== null) {
              net.linkVertexes(parent, mlabvert, link);
            }
          });
        }
      }
    }

    while (multiSpousePeople.count > 0) {
      // find all collections of people that are indirectly married to each other
      const node = multiSpousePeople.first();
      const cohort = new go.Set();
      this.extendCohort(cohort, node);
      const sorted = cohort.toArray();
      sorted.sort((a, b) => this.countMates(b) - this.countMates(a));
      const start = sorted[0];
      const map = new go.Map();
      this.walkMates(start, false, 1000000000, 500000000, map);
      sorted.sort((a, b) => map.get(a) - map.get(b));
      sorted.forEach(n => this._sortedNodes.set(n, sorted));
      const verts = [];
      const seen = new go.Set();
      for (let i = 0; i < sorted.length-1; i++) {
        const n = sorted[i];
        n.linksConnected.each(l => {
          if (l.category === "Mate") {
            const lab = l.labelNodes.first();
            if (lab) {
              const v = net.findVertex(lab);
              if (v && !seen.has(v)) {
                verts.push(v);
                seen.add(v);
              }
            }
          }
        })
      }
      // then encourage them all to be the same generation by connecting them all with a common vertex
      const dummyvert = net.createVertex();
      net.addVertex(dummyvert);
      for (let i = 0; i < verts.length; i++) {
        const v = verts[i];
        net.linkVertexes(dummyvert, v, null);
        // add pairings to try to keep the desired order
        if (i > 0) {
          const w = verts[i-1];
          const dummy = net.createVertex();
          net.addVertex(dummy);
          net.linkVertexes(dummy, w, null);
          net.linkVertexes(dummy, v, null);
          net.linkVertexes(dummy, w, null);
          net.linkVertexes(dummy, v, null);
        }
      }
      // done with these people, now see if there are any other multiple-married people
      multiSpousePeople.removeAll(cohort);
    }
  }

  // collect all of the people indirectly married with a person
  extendCohort(coll, node) {
    if (coll.has(node)) return;
    coll.add(node);
    node.linksConnected.each(l => {
      if (l.category === "Mate") {  // if it's a "Mate" link, continue with both spouses
        this.extendCohort(coll, l.fromNode);
        this.extendCohort(coll, l.toNode);
      }
    });
  }

  // how many Mate relationships does this person have?
  countMates(node) {
    let count = 0;
    node.linksConnected.each(l => {
      if (l.category === "Mate") count++;
    });
    return count;
  }

  walkMates(node, side, val, level, map) {
    if (map.has(node)) return;
    map.set(node, val);
    const count = this.countMates(node);
    level /= 2;
    let idx = 0;
    node.linksConnected.each(l => {
      if (l.category === "Mate") {
        const other = l.getOtherNode(node);
        if (map.has(other)) return;
        idx++;
        const newside = (idx <= count/2) ? side : !side;
        this.walkMates(other, newside, val + (newside ? level : -level), level, map);
      }
    });
  }

  assignLayers() {
    super.assignLayers();
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    // for every vertex, record the maximum vertex width or height for the vertex's layer
    const maxsizes = [];
    this.network.vertexes.each(v => {
      const lay = v.layer;
      let max = maxsizes[lay];
      if (max === undefined) max = 0;
      const sz = (horiz ? v.width : v.height);
      if (sz > max) maxsizes[lay] = sz;
    });
    // now make sure every vertex has the maximum width or height according to which layer it is in,
    // and aligned on the left (if horizontal) or the top (if vertical)
    this.network.vertexes.each(v => {
      const lay = v.layer;
      const max = maxsizes[lay];
      if (horiz) {
        v.focus = new go.Point(0, v.height / 2);
        v.width = max;
      } else {
        v.focus = new go.Point(v.width / 2, 0);
        v.height = max;
      }
    });
    // from now on, the LayeredDigraphLayout will think that the Node is bigger than it really is
    // (other than the ones that are the widest or tallest in their respective layer).
  }

  initializeIndices() {
    super.initializeIndices();
    const vertical = this.direction === 90 || this.direction === 270;
    this.network.edges.each(e => {
      if (e.fromVertex.node && e.fromVertex.node.isLinkLabel) {
        e.portFromPos = vertical ? e.fromVertex.focusX : e.fromVertex.focusY;
      }
      if (e.toVertex.node && e.toVertex.node.isLinkLabel) {
        e.portToPos = vertical ? e.toVertex.focusX : e.toVertex.focusY;
      }
    });
  }

  commitNodes() {
    super.commitNodes();
    // position regular nodes
    this.network.vertexes.each(v => {
      if (v.node !== null && !v.node.isLinkLabel) {
        v.node.position = new go.Point(v.x, v.y);
      }
    });
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    // position the spouses of each "Mate" vertex
    this.network.vertexes.each(v => {
      if (v.node === null) return;
      if (!v.node.isLinkLabel) return;
      const labnode = v.node;
      const lablink = labnode.labeledLink;
      // In case the spouses are not actually moved, we need to have the "Mate" link
      // position the label node, because LayoutVertex.commit() was called above on these vertexes.
      // Alternatively we could override LayoutVetex.commit to be a no-op for label node vertexes.
      lablink.invalidateRoute();
      let spouseA = lablink.fromNode;
      let spouseB = lablink.toNode;
      if (spouseA.opacity > 0 && spouseB.opacity > 0) {
        // maybe swap if multiple mates are on the other side
        const labA = this.findOtherMateLinkLabelNode(spouseA, lablink);
        const labB = this.findOtherMateLinkLabelNode(spouseB, lablink);
        if (labA) {
          const vA = this.network.findVertex(labA);
          if (vA && vA.x > v.x) {
            const temp = spouseA; spouseA = spouseB; spouseB = temp;
          }
        } else if (labB) {
          const vB = this.network.findVertex(labB);
          if (vB && vB.x < v.x) {
            const temp = spouseA; spouseA = spouseB; spouseB = temp;
          }
        }
        spouseA.moveTo(v.x, v.y);
        if (horiz) {
          spouseB.moveTo(v.x, v.y + spouseA.actualBounds.height + this.spouseSpacing);
        } else {
          spouseB.moveTo(v.x + spouseA.actualBounds.width + this.spouseSpacing, v.y);
        }
      } else if (spouseA.opacity === 0) {
        const pos = horiz
          ? new go.Point(v.x, v.centerY - spouseB.actualBounds.height / 2)
          : new go.Point(v.centerX - spouseB.actualBounds.width / 2, v.y);
        spouseB.move(pos);
        if (horiz) pos.y++; else pos.x++;
        spouseA.move(pos);
      } else if (spouseB.opacity === 0) {
        const pos = horiz
          ? new go.Point(v.x, v.centerY - spouseA.actualBounds.height / 2)
          : new go.Point(v.centerX - spouseA.actualBounds.width / 2, v.y);
        spouseA.move(pos);
        if (horiz) pos.y++; else pos.x++;
        spouseB.move(pos);
      }
    });
    this._sortedNodes.clear();
  }

  findOtherMateLinkLabelNode(node, link) {
    const it = node.linksConnected;
    while (it.next()) {
      const l = it.value;
      if (l.category === "Mate" && l !== link) return l.labelNodes.first();
    }
    return null;
  }
}  // end GenogramLayout class

class MateLink extends go.Link {
  constructor(init) {
    super();
    if (init) Object.assign(this, init);
  }

  computePoints() {
    const result = super.computePoints();
    if (result) {
      const lab = this.labelNodes.first();
      if (lab !== null) {
        let minx = Infinity;
        let maxx = -Infinity;
        lab.linksConnected.each(l => {
          const child = l.toNode;
          minx = Math.min(minx, child.actualBounds.x);
          maxx = Math.max(maxx, child.actualBounds.right);
        });
        if (minx < Infinity && this.midPoint.x < minx) {
          lab.segmentIndex = this.fromNode.location.x < this.toNode.location.x ? -2 : 1;
        } else if (maxx > -Infinity && this.midPoint.x > maxx) {
          lab.segmentIndex = this.fromNode.location.x < this.toNode.location.x ? 1 : -2;
        } else {
          lab.segmentIndex = -Infinity;
        }
      }
    }
    return result;
  }
}

// Navigation functions

function findParents(node) {  // returns an Array of zero or two Nodes
  const parents = [];
  if (!(node instanceof go.Node)) return parents;
  const parent = node.findTreeParentNode();
  if (parent && parent.category === "MateLabel") {
    const link = parent.labeledLink;
    if (link) {
      const from = link.fromNode;
      if (from) parents.push(from);
      const to = link.toNode;
      if (to) parents.push(to);
    }
  }
  return parents;
}

function findMates(node) {  // returns an Array of Nodes
  const mates = [];
  if (!(node instanceof go.Node)) return mates;
  node.findLinksConnected().each(link => {
    if (link.category === "Mate") {
      mates.push(link.getOtherNode(node));
    }
  });
  // ??? sort this collection
  return mates;
}

function findChildren(node, mate) {  // only children with mate; returns an Array of Nodes
  const children = [];
  node.findLinksConnected().each(link => {
    if (link.category === "Mate" &&
        (!mate || link.getOtherNode(node) === mate)) {
      link.labelNodes.each(label => {
        if (label.category === "MateLabel") {
          label.findNodesOutOf().each(child => {
            children.push(child);
          });
        }
      });
    }
  });
  // ??? sort this collection
  return children;
}

// initialize the Diagram, including its templates
function init() {
  myDiagram = new go.Diagram("myDiagramDiv", {
    //isReadOnly: true,
    // initial Diagram.scale will cause viewport to include the whole diagram
    initialAutoScale: go.AutoScale.Uniform,
    "animationManager.isInitial": false,
    "toolManager.hoverDelay": 100,  // quicker tooltips
    // if you want to limit how many Nodes or Links the user could select at one time
    //maxSelectionCount: 1,
    // use a custom layout, defined above
    layout:
      new GenogramLayout({ isInitial: false, direction: 90, layerSpacing: 20, columnSpacing: 10 }),
  });

  // conversion functions for the attribute/marker shapes

  function computeFill(attr) {
    switch (attr[0].toUpperCase()) {
      case "A": return '#5d8cc1';
      case "B": return '#775a4a';
      case "C": return '#94251e';
      case "D": return '#ca6958';
      case "E": return '#68bfaf';
      case "F": return '#23848a';
      case "G": return '#cfdf41';
      case "H": return '#717c42';
      case "V": return '#332d31';
      default:  return "white";
    }
  }

  function computeAlignment(idx) {
    return new go.Spot(0.5, 0.5, (idx & 1) === 0 ? -12.5 : 12.5, (idx & 2) === 0 ? -12.5 : 12.5);
  }

  myDiagram.nodeTemplate =  // representing a person
    new go.Node("Spot", {
        locationSpot: go.Spot.Center,
        layoutConditions: go.LayoutConditions.Standard & ~go.LayoutConditions.NodeSized,
        mouseEnter: (e, node) => highlightRelated(node, true),
        mouseLeave: (e, node) => highlightRelated(node, false)
      })
      .bind/*TwoWay*/("location", "loc", go.Point.parse/*, go.Point.stringifyFixed(1)*/)
      .add(
        // the main Shape: circle or square
        new go.Shape({
            name: "ICON",
            width: 50, height: 50,
            fill: "white", stroke: "black", strokeWidth: 1,
            portId: ""
          })
          .bind("figure", "gender", s => s[0] === "M" ? "Square" : (s[0] === "F" ? "Circle" : "Triangle"))
          .bind("fill"),
        new go.TextBlock().bind("text", "key"),
        // show at most 4 attribute/marker shapes
        new go.Panel("Spot", {
            isClipping: true,
            width: 49, height: 49,  // account for strokeWidth of main Shape
            itemTemplate: new go.Panel()
              .bindObject("alignment", "itemIndex", computeAlignment)
              .add(  // a square shape that fills a quadrant
                new go.Shape({
                    width: 25, height: 25, strokeWidth: 0,
                    toolTip: go.GraphObject.build("ToolTip")
                      .add(new go.TextBlock().bind("text", ""))
                  })
                  .bind("fill", "", computeFill)
              )
          })
          .bind("itemArray", "a")  // an Array of strings, such as ["X23", "ABC3", "qxz23m"]
          .add(
            // the main Shape: circle or square, used as a clipping mask
            new go.Shape({ width: 49, height: 49, strokeWidth: 0 })  // fill and stroke don't matter when clipping
              .bind("figure", "gender", s => s[0] === "M" ? "Square" : (s[0] === "F" ? "Circle" : "Triangle"))
          ),
        // proband marker
        new go.Shape({
                alignment: go.Spot.BottomLeft, alignmentFocus: go.Spot.TopRight,
                fill: "darkorange", stroke: "darkorange", strokeWidth: 3, scale: 2,
                geometryString: "F1 M20 0 L14.5 5.5 12 1z M18 1 L0 10"
              })
            .bindModel("visible", "proband", (key, shp) => shp.part.key === key),
        // highlight
        new go.Shape({ fill: null, stroke: null, strokeWidth: 4, width: 55, height: 55 })
          .bindObject("stroke", "isHighlighted", h => h ? "lightcoral" : null),
        // dead symbol: a slash
        new go.Shape({ opacity: 0, geometryString: "M60 0 L0 60" })
          .bind("opacity", "", data => (isDead(data) && (!data.reproduction || data.reproduction === "T" || data.reproduction ===  "SB")) ? 1 : 0),
        // adoption symbol: brackets
        new go.Shape({ opacity: 0, width: 55,  height: 55, geometryString: "M10 0 L0 0 0 55 10 55 M45 0 L55 0 55 55 45 55" })
          .bind("opacity", "adopted", ad => (ad === "in" || ad === "out") ? 1 : 0),
        // name
        new go.TextBlock({
            alignment: go.Spot.Bottom, alignmentFocus: new go.Spot(0.5, 0, 0, -5),
            height: 28,  // fixed height so that nodes are all the same height
            font: "bold 10pt sans-serif",
            textAlign: "center",
            maxSize: new go.Size(85, NaN),
            background: "rgba(255,255,255,0.75)",
            editable: true
          })
          .bindTwoWay("text", "name")
      );

  function highlightRelated(node, show) {
    if (show) {
      const parts = new go.Set();
      highlightAncestors(node, parts);
      highlightDependents(node, parts);
      if (node.diagram) node.diagram.highlightCollection(parts);
    } else {
      if (node.diagram) node.diagram.clearHighlighteds();
    }
  }

  function highlightAncestors(node, parts) {
    const parents = findParents(node);
    parts.addAll(parents);
    if (node.data.adopted === "in") return;
    parents.forEach(parent => highlightAncestors(parent, parts));
  }

  function highlightDependents(node, parts) {
    const children = findChildren(node);
    children.forEach(child => {
      if (child.data.adopted === "in") return;
      parts.add(child);
      highlightDependents(child, parts);
    });
  }

  function isDead(data) {  // the birth and death properties really ought to be dates in some form
    return !!data.death ? 1 : 0;
  }

  function scrollToData(persondata) {
    const node = myDiagram.findNodeForData(persondata);
    if (node) {
      node.diagram.select(node);
      setTimeout(() => node.diagram.commandHandler.scrollToPart(node), 1);
    }
  }

  myDiagram.linkTemplate =  // for parent-child relationships
    new go.Link({
        selectable: false,
        routing: go.Routing.Orthogonal, corner: 5, fromEndSegmentLength: 50,
        fromSpot: go.Spot.Bottom, toSpot: go.Spot.Top,
        layerName: "Background"
      })
      .bind/*TwoWay*/("points")
      .add(
        new go.Shape({ stroke: "black", strokeWidth: 2, strokeMiterLimit: 1 })
          .bindObject("strokeDashArray", "toNode", child => child.data.adopted === "in" ? [6, 4] : null)
          .bindObject("stroke", "isHighlighted", h => h ? "green" : "black")
      );

  myDiagram.linkTemplateMap.add("Mate",  // for relationships that produce offspring
    new MateLink({ // AvoidsNodes routing might be needed when people have multiple mates
        selectable: false,
        routing: go.Routing.AvoidsNodes, corner: 5,
        fromEndSegmentLength: 20, toEndSegmentLength: 20,
        fromSpot: go.Spot.LeftRightSides, toSpot: go.Spot.LeftRightSides,
        isTreeLink: false, layerName: "Background"
      })
      .bind/*TwoWay*/("points")
      .add(
        new go.Shape({ strokeWidth: 2, stroke: "blue" })
          .bindObject("stroke", "isHighlighted", h => h ? "green" : "blue"),
        new go.Shape({ visible: false, geometryString: "M12 0 L0 16 M16 0 L 4 16", segmentIndex: 1 })
          .bind("visible", "divorced")
      ));

  // The representation of the one label node on a "Mate" Link -- but nothing shows on a Mate Link.
  // Links to children come out from this node, not directly from the mother or the father nodes.
  myDiagram.nodeTemplateMap.add("MateLabel",
    new go.Node({
        selectable: false,
        width: 1, height: 1,
        locationSpot: go.Spot.Center
      })
      .bind/*TwoWay*/("location", "loc", go.Point.parse/*, go.Point.stringifyFixed(1)*/));


  // Initialize and implement the various HTML buttons

  // Load a model from Json text, displayed below the Diagram

  function load() {
    myDiagram.clear();
    const str = document.getElementById("mySavedModel").value;
    const m = go.Model.fromJson(str);
    // maybe convert data schema to GoJS GraphLinksModel
    // conversion needed when there's no linkDataArray
    if (m.linkDataArray.length === 0) {
      // add Mate links
      const linkDataArray = [];
      function findMateData(id0, id1) {
        for (const ld of linkDataArray) {
          // look for a "Mate" link that connects id0 and id1 in either direction
          if (ld.category === "Mate" &&
              ((ld.from === id0 && ld.to === id1) || (ld.from === id1 && ld.to === id0))) {
            return ld;
          }
        }
        return null;
      }
      m.nodeDataArray.forEach(d => {
        const rels = d.enduringRelationships;
        if (!rels || rels.length === 0) return;
        rels.forEach(r => {
          const mate = findMateData(d.key, r.personId);
          if (mate === null) {  // must not already exist
            const lab = { category: "MateLabel" };
            m.addNodeData(lab);
            linkDataArray.push({ category: "Mate", from: d.key, to: r.personId, labelKeys: [lab.key] });
          }
        });
      });
      // add mate-child links
      m.nodeDataArray.forEach(d => {
        const prnts = d.parents;
        if (!prnts || prnts.length !== 2) return;
        const p0 = prnts[0].personId;
        const p1 = prnts[1].personId;
        const mate = findMateData(p0, p1);
        if (mate !== null && mate.labelKeys && mate.labelKeys.length === 1) {
          linkDataArray.push({ from: mate.labelKeys[0], to: d.key });
        }
      });
      m.linkDataArray = linkDataArray;
    }
    myDiagram.model = m;
    myDiagram.model.pointsDigits = 1;  // limit decimals in JSON output for "points" Arrays
    // if not all person nodes have real locations, need to force a layout
    if (!myDiagram.nodes.all(node => node.isLinkLabel || node.location.isReal())) {
      myDiagram.layoutDiagram(true);
    }
  }

  function print() {
    const svgWindow = window.open();
    if (!svgWindow) return; // failure to open a new Window
    svgWindow.document.title = "Genogram";
    svgWindow.document.body.style.margin = "0px";
    const printSize = new go.Size(700, 960);
    const bnds = myDiagram.documentBounds;
    let x = bnds.x;
    let y = bnds.y;
    while (y < bnds.bottom) {
      while (x < bnds.right) {
        const svg = myDiagram.makeSvg({
          scale: 1.0,
          position: new go.Point(x, y),
          size: printSize,
          background: "white"
        });
        svgWindow.document.body.appendChild(svg);
        x += printSize.width;
      }
      x = bnds.x;
      y += printSize.height;
    }
    requestAnimationFrame(() => { svgWindow.print(); svgWindow.close(); });
  }
  document.getElementById("myPrintButton").addEventListener("click", print);

  document.getElementById("myDownloadButton").addEventListener("click", () => {
    myDiagram.commandHandler.downloadSvg({ name: "genogram.svg" });
  });

  function scrollToProband() {
    if (typeof myDiagram.model.modelData.proband === "number") {
      const node = myDiagram.findNodeForKey(myDiagram.model.modelData.proband);
      if (node) myDiagram.commandHandler.scrollToPart(node);
    }
  }
  document.getElementById("myScrollToProband").addEventListener("click", scrollToProband);

  
  function shuffle(a) {
    for (var i = a.length - 1; i >= 0; i--) {
      var r = Math.floor(Math.random() * (i + 1));
      var temp = a[i];
      a[i] = a[r];
      a[r] = temp;
    }
    return a;
  }

  function reloadRandomized() {
    var m = myDiagram.model;
    shuffle(m.nodeDataArray);
    shuffle(m.linkDataArray);
    document.getElementById("mySavedModel").value = m.toJson();
    load();
  }
  document.getElementById("myRandomize").addEventListener("click", reloadRandomized);

  load();
}  // end of init

window.addEventListener("DOMContentLoaded", init);
</script>

<div id="sample">
  <div style="position:relative">
    <div id="myDiagramDiv" style="background-color: #F8F8F8; border: solid 1px black; width:100%; height:600px;"></div>
  </div>
  <div>
    <button id="myPrintButton">Print</button>
    <button id="myDownloadButton">Download SVG</button>
    <button id="myScrollToProband">Scroll to Proband</button>
    <button id="myRandomize">Randomize data</button>
  </div>
  <!-- the rest is just for demonstration -->
  Diagram model saved in JSON format:
  <textarea id="mySavedModel" style="width:100%;height:250px">
{ "class": "GraphLinksModel",
  "copiesArrays": true,
  "pointsDigits": 1,
  "linkLabelKeysProperty": "labelKeys",
  "modelData": {"proband":6},
  "nodeDataArray": [
  {
    "key": 9138,
    "name": "Livia Pollo",
    "lastName": "Pollo",
    "gender": "Female",
    "parents": [],
    "enduringRelationships": [
      { "personId": 9140, "status": 0 },
      { "personId": 9153, "status": 2 }
    ]
  },
  {
    "key": 9153,
    "name": "Mick Pollo",
    "lastName": "Pollo",
    "gender": "Male",
    "parents": [],
    "enduringRelationships": [{ "personId": 9138, "status": 2 }]
  },
  {
    "key": 9137,
    "name": "BArry BAloon",
    "lastName": "BAloon",
    "gender": "Male",
    "parents": [
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9140,
    "name": "Ger CAKE",
    "lastName": "CAKE",
    "gender": "Male",
    "parents": [],
    "enduringRelationships": [
      { "personId": 9138, "status": 0 },
      { "personId": 9150, "status": 2 }
    ]
  },
  {
    "key": 9141,
    "name": "Carmella Soprano",
    "lastName": "Soprano",
    "gender": "Male",
    "parents": [
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9144,
    "name": "Jiminy cricket",
    "lastName": "cricket",
    "gender": "Male",
    "parents": [
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9150,
    "name": "Ex Wife xu",
    "lastName": "xu",
    "gender": "Female",
    "parents": [],
    "enduringRelationships": [{ "personId": 9140, "status": 2 }]
  },
  {
    "key": 9151,
    "name": "Ilya Xu",
    "lastName": "Xu",
    "gender": "Male",
    "parents": [
      { "personId": 9150, "relationship": 0, "relationshipLabel": "Son" },
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Son" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9152,
    "name": "banjo Kazooie",
    "lastName": "Kazooie",
    "gender": "Female",
    "parents": [
      { "personId": 9153, "relationship": 0, "relationshipLabel": "Daughter" },
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Daughter" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9136,
    "name": "MAry Cake",
    "lastName": "Cake",
    "gender": "Female",
    "parents": [
      { "personId": 9138, "relationship": 0, "relationshipLabel": "Daughter" },
      { "personId": 9140, "relationship": 0, "relationshipLabel": "Daughter" }
    ],
    "enduringRelationships": []
  },
  {
    "key": 9199,
    "name": "JOhn Wall",
    "lastName": "Wall",
    "gender": "Male",
    "enduringRelationships": [{ "personId": 9136 }]
  }
]}
 </textarea>
</div>
</body>
</html>

Thanks Walter,

The multiple partners and half siblings are much improved,
Our last stumbling block is drawing child lines to nodes

    { "class": "GraphLinksModel",
    "copiesArrays": true,
    "linkLabelKeysProperty": "labelKeys",
    "nodeDataArray": [
  {"key":9138,"name":"Livia Pollo","lastName":"Pollo","gender":"Female","dateOfBirth":null,"estimatedAge":50,"address":"Tusla, Tusla Building","isDeceased":false,"isUnborn":false,"parents":[],"enduringRelationships":[{"personId":9140,"status":0},{"personId":9153,"status":2}],"transitoryRelationships":[],"connectionDetails":{"connID":54481,"connectionTypeName":"Mother","reverseConnectionTypeName":"Daughter","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":true,"isCaseToPerson":false}},
  {"key":9153,"name":"Mick Pollo","lastName":"Pollo","gender":"Male","dateOfBirth":null,"estimatedAge":null,"address":"Tusla, 134 Bank Place","isDeceased":false,"isUnborn":false,"parents":[],"enduringRelationships":[{"personId":9138,"status":2}],"transitoryRelationships":[],"connectionDetails":{"connID":54587,"connectionTypeName":"Adult","reverseConnectionTypeName":"Not Defined","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":false,"isCaseToPerson":false}},
  {"key":9137,"name":"BArry BAloon","lastName":"BAloon","gender":"Male","dateOfBirth":null,"estimatedAge":1,"address":"Tusla, Tusla Building","isDeceased":false,"isUnborn":false,"parents":[{"personId":9138,"relationship":0,"relationshipLabel":"Son"},{"personId":9140,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"transitoryRelationships":[],"connectionDetails":{"connID":54478,"connectionTypeName":"Brother","reverseConnectionTypeName":"Sister","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":true,"isCaseToPerson":false}},
  {"key":9140,"name":"Ger CAKE","lastName":"CAKE","gender":"Male","dateOfBirth":null,"estimatedAge":80,"address":"Tusla","isDeceased":false,"isUnborn":false,"parents":[],"enduringRelationships":[{"personId":9138,"status":0},{"personId":9150,"status":2}],"transitoryRelationships":[],"connectionDetails":{"connID":54489,"connectionTypeName":"Father","reverseConnectionTypeName":"Daughter","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":false,"isCaseToPerson":false}},
  {"key":9141,"name":"Carmella Soprano","lastName":"Soprano","gender":"Male","dateOfBirth":null,"estimatedAge":5,"address":"Tusla, Tusla Building","isDeceased":false,"isUnborn":false,"parents":[{"personId":9140,"relationship":0,"relationshipLabel":"Son"},{"personId":9138,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"transitoryRelationships":[],"connectionDetails":{"connID":54500,"connectionTypeName":"Brother","reverseConnectionTypeName":"Sister","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":true,"isCaseToPerson":false}},
  {"key":9144,"name":"Jiminy cricket","lastName":"cricket","gender":"Male","dateOfBirth":null,"estimatedAge":60,"address":"Tusla, 134 Bank Place","isDeceased":false,"isUnborn":false,"parents":[{"personId":9140,"relationship":0,"relationshipLabel":"Son"},{"personId":9138,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"transitoryRelationships":[],"connectionDetails":{"connID":54519,"connectionTypeName":"Brother","reverseConnectionTypeName":"Sister","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":false,"isCaseToPerson":false}},
  {"key":9150,"name":"Ex Wife xu","lastName":"xu","gender":"Female","dateOfBirth":null,"estimatedAge":null,"address":"Tusla, Tusla Building","isDeceased":false,"isUnborn":false,"parents":[],"enduringRelationships":[{"personId":9140,"status":2}],"transitoryRelationships":[],"connectionDetails":{"connID":54555,"connectionTypeName":"Adult","reverseConnectionTypeName":"Not Defined","reporterType":"Select","isFamilyMember":false,"isHouseHoldMember":true,"isCaseToPerson":false}},
  {"key":9151,"name":"Ilya Xu","lastName":"Xu","gender":"Male","dateOfBirth":null,"estimatedAge":1,"address":"Tusla, Unit 4, Shannonside Retail Park","isDeceased":false,"isUnborn":false,"parents":[{"personId":9150,"relationship":0,"relationshipLabel":"Son"},{"personId":9140,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"transitoryRelationships":[],"connectionDetails":{"connID":54573,"connectionTypeName":"Step Brother","reverseConnectionTypeName":"Step Sister","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":false,"isCaseToPerson":false}},
  {"key":9152,"name":"banjo Kazooie","lastName":"Kazooie","gender":"Female","dateOfBirth":null,"estimatedAge":null,"address":"Tusla, Unit 4, Shannonside Retail Park","isDeceased":false,"isUnborn":false,"parents":[{"personId":9138,"relationship":0,"relationshipLabel":"Daughter"}],"enduringRelationships":[],"transitoryRelationships":[],"connectionDetails":{"connID":54576,"connectionTypeName":"Step Brother","reverseConnectionTypeName":"Step Sister","reporterType":"Select","isFamilyMember":true,"isHouseHoldMember":false,"isCaseToPerson":false}},
  {"key":9136,"name":"MAry Cake","lastName":"Cake","gender":"Female","dateOfBirth":null,"estimatedAge":null,"address":"Tusla, Tusla Building","isDeceased":false,"isUnborn":false,"parents":[{"personId":9138,"relationship":0,"relationshipLabel":"Daughter"},{"personId":9140,"relationship":0,"relationshipLabel":"Daughter"}],"enduringRelationships":[],"transitoryRelationships":[],"connectionDetails":{"connID":54476,"connectionTypeName":"Case Person","reverseConnectionTypeName":null,"reporterType":null,"startDate":{"ts":1742342400000,"loc":{"locale":"en-US","numberingSystem":null,"outputCalendar":null,"intl":"en-US","weekdaysCache":{"format":{},"standalone":{}},"monthsCache":{"format":{},"standalone":{}},"meridiemCache":null,"eraCache":{},"specifiedLocale":null,"fastNumbersCached":null},"invalid":null,"weekData":null,"c":{"year":2025,"month":3,"day":19,"hour":0,"minute":0,"second":0,"millisecond":0},"o":0,"isLuxonDateTime":true},"isFamilyMember":false,"isHouseHoldMember":false,"isCaseToPerson":true}},
  {"category":"MateLabel","key":-11},
  {"category":"MateLabel","key":-12},
  {"category":"MateLabel","key":-13}
  ],
  "modelData":{"proband" : 9136},
    "linkDataArray": [
  {"category":"Mate","from":9138,"to":9140,"labelKeys":[-11]},
  {"category":"Mate","from":9140,"to":9150,"labelKeys":[-13]},
  {"from":-11,"to":9137},
  {"from":-11,"to":9141},
  {"from":-11,"to":9144},
  {"from":-13,"to":9151},
  {"from":-11,"to":9136},
  {"from":9138,"to":9152}

  ]}

we would ideally like to get towards a stage where it looks like this

In your opinion is this doable within the constraints of the layout ?

Would it be best to add a hidden label node behind the node for the line to be drawn to or is there samples of people who have been able to have single and multiple parents within this type of diagram ?

OK, try this. But I’m not going to be able to work on this layout any more for you.

<!DOCTYPE html>
<html>
<body>
  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">

// A custom layout that shows the two families related to a person's parents
class GenogramLayout extends go.LayeredDigraphLayout {
  constructor(init) {
    super();
    this.initializeOption = go.LayeredDigraphInit.Naive;
    this.spouseSpacing = 30;  // minimum space between spouses
    this.isRouting = false;
    if (init) Object.assign(this, init);
  }

  makeNetwork(coll) {
    // generate LayoutEdges for each parent-child Link
    const net = this.createNetwork();
    if (coll instanceof go.Diagram) {
      this.add(net, coll.nodes, true);
      this.add(net, coll.links, true);
    } else if (coll instanceof go.Group) {
      this.add(net, coll.memberParts, false);
    } else if (coll.iterator) {
      this.add(net, coll.iterator, false);
    }
    return net;
  }

  // internal method for creating LayeredDigraphNetwork where husband/wife pairs are represented
  // by a single LayeredDigraphVertex corresponding to the label Node on the "Mate" Link
  add(net, coll, nonmemberonly) {
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    const nodes = coll.filter(node => {
      if (!(node instanceof go.Node) || !node.data) return false;
      if (!node.isLayoutPositioned || !node.isVisible()) return false;
      if (nonmemberonly && node.containingGroup !== null) return false;
      return true;
    });
    const multiSpousePeople = new go.Set();
    // consider all people Nodes in the given collection
    nodes.each(node => {
      if (node.isLinkLabel) return;
      net.addNode(node);
      if (this.countMates(node) > 1) {
        multiSpousePeople.add(node);  // handle separately, below
      }
    });
    // add child edges for unmated people
    nodes.each(node => {
      if (node.isLinkLabel || this.countMates(node) > 0) return;
      this.addEdgesForChildLinks(node, net);
    });
    // now handle all mate link label nodes
    nodes.each(node => {
      if (!node.isLinkLabel) return;
      // get "Mate" Link
      const link = node.labeledLink;
      if (link.category !== "Mate") return;
      const spouseA = link.fromNode;
      const spouseB = link.toNode;
      // create vertex representing both husband and wife
      const vertex = net.addNode(node);
      // now define the vertex size to be big enough to hold both spouses
      if (horiz) {
        vertex.height = spouseA.actualBounds.height + this.spouseSpacing + spouseB.actualBounds.height;
        vertex.width = Math.max(spouseA.actualBounds.width, spouseB.actualBounds.width);
        vertex.focus = new go.Point(vertex.width / 2, spouseA.actualBounds.height + this.spouseSpacing / 2);
      } else {
        vertex.width = spouseA.actualBounds.width + this.spouseSpacing + spouseB.actualBounds.width;
        vertex.height = Math.max(spouseA.actualBounds.height, spouseB.actualBounds.height);
        vertex.focus = new go.Point(spouseA.actualBounds.width + this.spouseSpacing / 2, vertex.height / 2);
      }
      if (this.countMates(spouseA) === 1 && this.countMates(spouseB) === 1) {
        this.addEdgesForChildLinks(spouseA, net);
        this.addEdgesForChildLinks(node, net);
        this.addEdgesForChildLinks(spouseB, net);
        const a = net.findVertex(spouseA);
        const b = net.findVertex(spouseB);
        const dummyA = net.createVertex();
        net.addVertex(dummyA);
        net.linkVertexes(dummyA, a, null);
        net.linkVertexes(dummyA, vertex, null);
        net.linkVertexes(dummyA, a, null);
        net.linkVertexes(dummyA, vertex, null);
        const dummyB = net.createVertex();
        net.addVertex(dummyB);
        net.linkVertexes(dummyB, vertex, null);
        net.linkVertexes(dummyB, b, null);
        net.linkVertexes(dummyB, vertex, null);
        net.linkVertexes(dummyB, b, null);
      } // otherwise handled below with multiSpousePeople
    });

    while (multiSpousePeople.count > 0) {
      // find all collections of people that are indirectly married to each other
      const node = multiSpousePeople.first();
      const cohort = new go.Set();
      this.extendCohort(cohort, node);
      const sorted = cohort.toArray();
      sorted.sort((a, b) => this.countMates(b) - this.countMates(a));
      sorted.forEach(n => net.addNode(n));
      const start = sorted[0];
      const map = new go.Map();
      this.walkMates(start, false, 1000000000, 500000000, map);
      sorted.sort((a, b) => map.get(a) - map.get(b));
      const verts = [];
      const seen = new go.Set();
      for (let i = 0; i < sorted.length; i++) {
        const n = sorted[i];
        const nv = net.findVertex(n);
        if (nv && !seen.has(nv)) {
          verts.push(nv);
          seen.add(nv);
        }
        if (i < sorted.length-1) {
          const n2 = sorted[i+1];
          const mlink = n.findLinksBetween(n2).first();
          if (mlink) {
            const lab = mlink.labelNodes.first();
            if (lab) {
              const v = net.findVertex(lab);
              if (v && !seen.has(v)) {
                verts.push(v);
                seen.add(v);
              }
            }
          }
        }
      }
      // then encourage them all to be the same generation by connecting them all with a common vertex
      const dummyvert = net.createVertex();
      net.addVertex(dummyvert);
      for (let i = 0; i < verts.length; i++) {
        const v = verts[i];
        const n = v.node;
        if (n) this.addEdgesForChildLinks(n, net);
        // add pairings to try to keep the desired order
        if (i > 0) {
          const dummy = net.createVertex();
          net.addVertex(dummy);
          net.linkVertexes(dummyvert, dummy, null);
          const w = verts[i-1];
          net.linkVertexes(dummy, w, null);
          net.linkVertexes(dummy, v, null);
          net.linkVertexes(dummy, w, null);
          net.linkVertexes(dummy, v, null);
        }
      }
      // done with these people, now see if there are any other multiple-married people
      multiSpousePeople.removeAll(cohort);
    }
  }

  // collect all of the people indirectly married with a person
  extendCohort(coll, node) {
    if (coll.has(node)) return;
    coll.add(node);
    node.linksConnected.each(l => {
      if (l.category === "Mate") {  // if it's a "Mate" link, continue with both spouses
        this.extendCohort(coll, l.fromNode);
        this.extendCohort(coll, l.toNode);
      }
    });
  }

  addEdgesForChildLinks(node, net) {
    node.linksConnected.each(l => {
      if (l.category === "" && l.fromNode === node) {
        net.addLink(l);
      }
    });
  }

  findCommonVertex(v0, v1, net) {
    const n0 = v0.node;
    if (!n0 || !n0.isLinkLabel) return null;
    const l0 = n0.labeledLink;
    if (!l0) return null;
    const n1 = v1.node;
    if (!n1 || !n1.isLinkLabel) return null;
    const l1 = n1.labeledLink;
    if (!l1) return null;
    const l0f = l0.fromNode;
    if (l0f && (l0f === l1.fromNode || l0f === l1.toNode)) return net.findVertex(l0f);
    const l0t = l0.toNode;
    if (l0t && (l0t === l1.fromNode || l0t === l1.toNode)) return net.findVertex(l0t);
    return null;
  }

  countDirectChildren(node) {
    let count = 0;
    node.linksConnected.each(l => {
      if (l.category === "" && l.fromNode === node) count++;
    });
    return count;
  }

  // how many Mate relationships does this person have?
  countMates(node) {
    let count = 0;
    node.linksConnected.each(l => {
      if (l.category === "Mate") count++;
    });
    return count;
  }

  walkMates(node, side, val, level, map) {
    if (map.has(node)) return;
    map.set(node, val);
    const count = this.countMates(node);
    level /= 2;
    let idx = 0;
    node.linksConnected.each(l => {
      if (l.category === "Mate") {
        const other = l.getOtherNode(node);
        if (map.has(other)) return;
        idx++;
        const newside = (idx <= count/2) ? side : !side;
        this.walkMates(other, newside, val + (newside ? level : -level), level, map);
      }
    });
  }

  assignLayers() {
    super.assignLayers();
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    // for every vertex, record the maximum vertex width or height for the vertex's layer
    const maxsizes = [];
    this.network.vertexes.each(v => {
      const lay = v.layer;
      let max = maxsizes[lay];
      if (max === undefined) max = 0;
      const sz = (horiz ? v.width : v.height);
      if (sz > max) maxsizes[lay] = sz;
    });
    // now make sure every vertex has the maximum width or height according to which layer it is in,
    // and aligned on the left (if horizontal) or the top (if vertical)
    this.network.vertexes.each(v => {
      const lay = v.layer;
      const max = maxsizes[lay];
      if (horiz) {
        v.focus = new go.Point(0, v.height / 2);
        v.width = max;
      } else {
        v.focus = new go.Point(v.width / 2, 0);
        v.height = max;
      }
    });
    // from now on, the LayeredDigraphLayout will think that the Node is bigger than it really is
    // (other than the ones that are the widest or tallest in their respective layer).
  }

  initializeIndices() {
    super.initializeIndices();
    const vertical = this.direction === 90 || this.direction === 270;
    this.network.edges.each(e => {
      if (e.fromVertex.node && e.fromVertex.node.isLinkLabel) {
        e.portFromPos = vertical ? e.fromVertex.focusX : e.fromVertex.focusY;
      }
      if (e.toVertex.node && e.toVertex.node.isLinkLabel) {
        e.portToPos = vertical ? e.toVertex.focusX : e.toVertex.focusY;
      }
    });
  }

  commitNodes() {
    super.commitNodes();
    // position regular nodes
    this.network.vertexes.each(v => {
      if (v.node !== null && !v.node.isLinkLabel) {
        v.node.position = new go.Point(v.x, v.y);
      }
    });
    const horiz = this.direction == 0.0 || this.direction == 180.0;
    // position the spouses of each "Mate" vertex
    this.network.vertexes.each(v => {
      if (v.node === null) return;
      if (!v.node.isLinkLabel) return;
      const labnode = v.node;
      const lablink = labnode.labeledLink;
      // In case the spouses are not actually moved, we need to have the "Mate" link
      // position the label node, because LayoutVertex.commit() was called above on these vertexes.
      // Alternatively we could override LayoutVetex.commit to be a no-op for label node vertexes.
      lablink.invalidateRoute();
      let spouseA = lablink.fromNode;
      let spouseB = lablink.toNode;
      if (spouseA.opacity > 0 && spouseB.opacity > 0) {
        // maybe swap if multiple mates are on the other side
        const labA = this.findOtherMateLinkLabelNode(spouseA, lablink);
        const labB = this.findOtherMateLinkLabelNode(spouseB, lablink);
        if (labA) {
          const vA = this.network.findVertex(labA);
          if (vA && vA.x > v.x) {
            const temp = spouseA; spouseA = spouseB; spouseB = temp;
          }
        } else if (labB) {
          const vB = this.network.findVertex(labB);
          if (vB && vB.x < v.x) {
            const temp = spouseA; spouseA = spouseB; spouseB = temp;
          }
        }
        spouseA.moveTo(v.x, v.y);
        if (horiz) {
          spouseB.moveTo(v.x, v.y + spouseA.actualBounds.height + this.spouseSpacing);
        } else {
          spouseB.moveTo(v.x + spouseA.actualBounds.width + this.spouseSpacing, v.y);
        }
      } else if (spouseA.opacity === 0) {
        const pos = horiz
          ? new go.Point(v.x, v.centerY - spouseB.actualBounds.height / 2)
          : new go.Point(v.centerX - spouseB.actualBounds.width / 2, v.y);
        spouseB.move(pos);
        if (horiz) pos.y++; else pos.x++;
        spouseA.move(pos);
      } else if (spouseB.opacity === 0) {
        const pos = horiz
          ? new go.Point(v.x, v.centerY - spouseA.actualBounds.height / 2)
          : new go.Point(v.centerX - spouseA.actualBounds.width / 2, v.y);
        spouseA.move(pos);
        if (horiz) pos.y++; else pos.x++;
        spouseB.move(pos);
      }
    });
  }

  findOtherMateLinkLabelNode(node, link) {
    const it = node.linksConnected;
    while (it.next()) {
      const l = it.value;
      if (l.category === "Mate" && l !== link) return l.labelNodes.first();
    }
    return null;
  }
}  // end GenogramLayout class

class MateLink extends go.Link {
  constructor(init) {
    super();
    if (init) Object.assign(this, init);
  }

  computePoints() {
    const result = super.computePoints();
    if (result) {
      const lab = this.labelNodes.first();
      if (lab !== null) {
        let minx = Infinity;
        let maxx = -Infinity;
        lab.linksConnected.each(l => {
          const child = l.toNode;
          minx = Math.min(minx, child.actualBounds.x);
          maxx = Math.max(maxx, child.actualBounds.right);
        });
        if (minx < Infinity && this.midPoint.x < minx) {
          lab.segmentIndex = this.fromNode.location.x < this.toNode.location.x ? -2 : 1;
        } else if (maxx > -Infinity && this.midPoint.x > maxx) {
          lab.segmentIndex = this.fromNode.location.x < this.toNode.location.x ? 1 : -2;
        } else {
          lab.segmentIndex = -Infinity;
        }
      }
    }
    return result;
  }
}

// Navigation functions

function findParents(node) {  // returns an Array of Nodes
  const parents = [];
  if (!(node instanceof go.Node)) return parents;
  const parent = node.findTreeParentNode();
  if (parent) {
    if (parent.category === "") {
      parents.push(parent);
    } else if (parent.category === "MateLabel") {
      const link = parent.labeledLink;
      if (link) {
        const from = link.fromNode;
        if (from) parents.push(from);
        const to = link.toNode;
        if (to) parents.push(to);
      }
    }
  }
  return parents;
}

function findChildren(node, mate) {  // only children with mate; returns an Array of Nodes
  const children = [];
  node.findLinksConnected().each(link => {
    if (link.category === "" && link.fromNode === node) {
      children.push(link.toNode);
    } else if (link.category === "Mate" &&
        (!mate || link.getOtherNode(node) === mate)) {
      link.labelNodes.each(label => {
        if (label.category === "MateLabel") {
          label.findNodesOutOf().each(child => {
            children.push(child);
          });
        }
      });
    }
  });
  return children;
}

// initialize the Diagram, including its templates
function init() {
  myDiagram = new go.Diagram("myDiagramDiv", {
    //isReadOnly: true,
    // initial Diagram.scale will cause viewport to include the whole diagram
    initialAutoScale: go.AutoScale.Uniform,
    "animationManager.isInitial": false,
    "toolManager.hoverDelay": 100,  // quicker tooltips
    // if you want to limit how many Nodes or Links the user could select at one time
    //maxSelectionCount: 1,
    // use a custom layout, defined above
    layout:
      new GenogramLayout({ isInitial: false, direction: 90, layerSpacing: 20, columnSpacing: 10 }),
  });

  // conversion functions for the attribute/marker shapes

  function computeFill(attr) {
    switch (attr[0].toUpperCase()) {
      case "A": return '#5d8cc1';
      case "B": return '#775a4a';
      case "C": return '#94251e';
      case "D": return '#ca6958';
      case "E": return '#68bfaf';
      case "F": return '#23848a';
      case "G": return '#cfdf41';
      case "H": return '#717c42';
      case "V": return '#332d31';
      default:  return "white";
    }
  }

  function computeAlignment(idx) {
    return new go.Spot(0.5, 0.5, (idx & 1) === 0 ? -12.5 : 12.5, (idx & 2) === 0 ? -12.5 : 12.5);
  }

  myDiagram.nodeTemplate =  // representing a person
    new go.Node("Spot", {
        locationSpot: go.Spot.Center,
        layoutConditions: go.LayoutConditions.Standard & ~go.LayoutConditions.NodeSized,
        mouseEnter: (e, node) => highlightRelated(node, true),
        mouseLeave: (e, node) => highlightRelated(node, false)
      })
      .bind/*TwoWay*/("location", "loc", go.Point.parse/*, go.Point.stringifyFixed(1)*/)
      .add(
        // the main Shape: circle or square
        new go.Shape({
            name: "ICON",
            width: 50, height: 50,
            fill: "white", stroke: "black", strokeWidth: 1,
            portId: ""
          })
          .bind("figure", "gender", s => s[0] === "M" ? "Square" : (s[0] === "F" ? "Circle" : "Triangle"))
          .bind("fill"),
        new go.TextBlock().bind("text", "key"),
        // show at most 4 attribute/marker shapes
        new go.Panel("Spot", {
            isClipping: true,
            width: 49, height: 49,  // account for strokeWidth of main Shape
            itemTemplate: new go.Panel()
              .bindObject("alignment", "itemIndex", computeAlignment)
              .add(  // a square shape that fills a quadrant
                new go.Shape({
                    width: 25, height: 25, strokeWidth: 0,
                    toolTip: go.GraphObject.build("ToolTip")
                      .add(new go.TextBlock().bind("text", ""))
                  })
                  .bind("fill", "", computeFill)
              )
          })
          .bind("itemArray", "a")  // an Array of strings, such as ["X23", "ABC3", "qxz23m"]
          .add(
            // the main Shape: circle or square, used as a clipping mask
            new go.Shape({ width: 49, height: 49, strokeWidth: 0 })  // fill and stroke don't matter when clipping
              .bind("figure", "gender", s => s[0] === "M" ? "Square" : (s[0] === "F" ? "Circle" : "Triangle"))
          ),
        // proband marker
        new go.Shape({
                alignment: go.Spot.BottomLeft, alignmentFocus: go.Spot.TopRight,
                fill: "darkorange", stroke: "darkorange", strokeWidth: 3, scale: 2,
                geometryString: "F1 M20 0 L14.5 5.5 12 1z M18 1 L0 10"
              })
            .bindModel("visible", "proband", (key, shp) => shp.part.key === key),
        // highlight
        new go.Shape({ fill: null, stroke: null, strokeWidth: 4, width: 55, height: 55 })
          .bindObject("stroke", "isHighlighted", h => h ? "lightcoral" : null),
        // dead symbol: a slash
        new go.Shape({ opacity: 0, geometryString: "M60 0 L0 60" })
          .bind("opacity", "", data => isDead(data) ? 1 : 0),
        // adoption symbol: brackets
        new go.Shape({ opacity: 0, width: 55,  height: 55, geometryString: "M10 0 L0 0 0 55 10 55 M45 0 L55 0 55 55 45 55" })
          .bind("opacity", "adopted", ad => (ad === "in" || ad === "out") ? 1 : 0),
        // name
        new go.TextBlock({
            alignment: go.Spot.Bottom, alignmentFocus: new go.Spot(0.5, 0, 0, -5),
            height: 28,  // fixed height so that nodes are all the same height
            font: "bold 10pt sans-serif",
            textAlign: "center",
            maxSize: new go.Size(85, NaN),
            background: "rgba(255,255,255,0.75)",
            editable: true
          })
          .bindTwoWay("text", "name")
      );

  function highlightRelated(node, show) {
    if (show) {
      const parts = new go.Set();
      highlightAncestors(node, parts);
      highlightDependents(node, parts);
      if (node.diagram) node.diagram.highlightCollection(parts);
    } else {
      if (node.diagram) node.diagram.clearHighlighteds();
    }
  }

  function highlightAncestors(node, parts) {
    const parents = findParents(node);
    parts.addAll(parents);
    if (node.data.adopted === "in") return;
    parents.forEach(parent => highlightAncestors(parent, parts));
  }

  function highlightDependents(node, parts) {
    const children = findChildren(node);
    children.forEach(child => {
      if (child.data.adopted === "in") return;
      parts.add(child);
      highlightDependents(child, parts);
    });
  }

  function isDead(data) {  // the birth and death properties really ought to be dates in some form
    return data.isDeceased;
  }

  myDiagram.linkTemplate =  // for parent-child relationships
    new go.Link({
        selectable: false,
        routing: go.Routing.Orthogonal, corner: 5,
        fromSpot: go.Spot.Bottom, toSpot: go.Spot.Top,
        layerName: "Background"
      })
      .bindObject("fromEndSegmentLength", "", link => link.fromNode.isLinkLabel ? 70 : 30)
      .add(
        new go.Shape({ stroke: "black", strokeWidth: 2, strokeMiterLimit: 1 })
          .bindObject("strokeDashArray", "toNode", child => child.data.adopted === "in" ? [6, 4] : null)
          .bindObject("stroke", "isHighlighted", h => h ? "green" : "black")
      );

  myDiagram.linkTemplateMap.add("Mate",  // for relationships that produce offspring
    new MateLink({ // AvoidsNodes routing might be needed when people have multiple mates
        selectable: false,
        routing: go.Routing.AvoidsNodes, corner: 5,
        fromEndSegmentLength: 20, toEndSegmentLength: 20,
        fromSpot: go.Spot.LeftRightSides, toSpot: go.Spot.LeftRightSides,
        isTreeLink: false, layerName: "Background"
      })
      .add(
        new go.Shape({ strokeWidth: 2, stroke: "blue" })
          .bindObject("stroke", "isHighlighted", h => h ? "green" : "blue"),
        new go.Shape({ visible: false, geometryString: "M12 0 L0 16 M16 0 L 4 16", segmentIndex: 1 })
          .bind("visible", "divorced")
      ));

  // The representation of the one label node on a "Mate" Link -- but nothing shows on a Mate Link.
  // Links to children come out from this node, not directly from the mother or the father nodes.
  myDiagram.nodeTemplateMap.add("MateLabel",
    new go.Node({
        selectable: false,
        width: 1, height: 1,
        locationSpot: go.Spot.Center
      })
      .bind/*TwoWay*/("location", "loc", go.Point.parse/*, go.Point.stringifyFixed(1)*/));


  // Load a model from Json text, displayed below the Diagram
  function load() {
    myDiagram.clear();
    const str = document.getElementById("mySavedModel").value;
    const m = go.Model.fromJson(str);
    // maybe convert data schema to GoJS GraphLinksModel
    // conversion needed when there's no linkDataArray
    if (m.linkDataArray.length === 0) {
      // add Mate links
      const linkDataArray = [];
      function findMateData(id0, id1) {
        for (const ld of linkDataArray) {
          // look for a "Mate" link that connects id0 and id1 in either direction
          if (ld.category === "Mate" &&
              ((ld.from === id0 && ld.to === id1) || (ld.from === id1 && ld.to === id0))) {
            return ld;
          }
        }
        return null;
      }
      m.nodeDataArray.forEach(d => {
        const rels = d.enduringRelationships;
        if (!rels || rels.length === 0) return;
        rels.forEach(r => {
          const mate = findMateData(d.key, r.personId);
          if (mate === null) {  // must not already exist
            const lab = { category: "MateLabel" };
            m.addNodeData(lab);
            linkDataArray.push({ category: "Mate", from: d.key, to: r.personId, labelKeys: [lab.key] });
          }
        });
      });
      // add mate-child links
      m.nodeDataArray.forEach(d => {
        const prnts = d.parents;
        if (!prnts || prnts.length !== 2) return;
        const p0 = prnts[0].personId;
        const p1 = prnts[1].personId;
        const mate = findMateData(p0, p1);
        if (mate !== null && mate.labelKeys && mate.labelKeys.length === 1) {
          linkDataArray.push({ from: mate.labelKeys[0], to: d.key });
        }
      });
      m.linkDataArray = linkDataArray;
    }
    myDiagram.model = m;
    myDiagram.model.pointsDigits = 1;  // limit decimals in JSON output for "points" Arrays
    // if not all person nodes have real locations, need to force a layout
    if (!myDiagram.nodes.all(node => node.isLinkLabel || node.location.isReal())) {
      myDiagram.layoutDiagram(true);
    }
  }
  
  function shuffle(a) {
    for (var i = a.length - 1; i >= 0; i--) {
      var r = Math.floor(Math.random() * (i + 1));
      var temp = a[i];
      a[i] = a[r];
      a[r] = temp;
    }
    return a;
  }

  function reloadRandomized() {
    var m = myDiagram.model;
    shuffle(m.nodeDataArray);
    shuffle(m.linkDataArray);
    document.getElementById("mySavedModel").value = m.toJson();
    load();
  }
  document.getElementById("myRandomize").addEventListener("click", reloadRandomized);

  load();
}  // end of init

window.addEventListener("DOMContentLoaded", init);
</script>

<div id="sample">
  <div style="position:relative">
    <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px;"></div>
  </div>
  <div>
    <button id="myRandomize">Randomize data</button>
  </div>
  <textarea id="mySavedModel" style="width:100%;height:250px">
  { "class": "GraphLinksModel",
    "copiesArrays": true,
    "linkLabelKeysProperty": "labelKeys",
    "nodeDataArray": [
  {"key":9138,"name":"Livia Pollo","lastName":"Pollo","gender":"Female","estimatedAge":50,"isDeceased":false,"parents":[],"enduringRelationships":[{"personId":9140,"status":0},{"personId":9153,"status":2}],"connectionDetails":{"connID":54481,"connectionTypeName":"Mother"}},
  {"key":9153,"name":"Mick CAKE","lastName":"Pollo","gender":"Male","estimatedAge":null,"isDeceased":false,"parents":[],"enduringRelationships":[{"personId":9138,"status":2}],"connectionDetails":{"connID":54587,"connectionTypeName":"Adult"}},
  {"key":9137,"name":"BArry BAloon","lastName":"BAloon","gender":"Male","estimatedAge":1,"isDeceased":false,"parents":[{"personId":9138,"relationship":0,"relationshipLabel":"Son"},{"personId":9140,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"connectionDetails":{"connID":54478,"connectionTypeName":"Brother"}},
  {"key":9140,"name":"Ger CAKE","lastName":"CAKE","gender":"Male","estimatedAge":80,"isDeceased":false,"parents":[],"enduringRelationships":[{"personId":9138,"status":0},{"personId":9150,"status":2}],"connectionDetails":{"connID":54489,"connectionTypeName":"Father"}},
  {"key":9141,"name":"Carmella Soprano","lastName":"Soprano","gender":"Male","estimatedAge":5,"isDeceased":false,"parents":[{"personId":9140,"relationship":0,"relationshipLabel":"Son"},{"personId":9138,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"connectionDetails":{"connID":54500,"connectionTypeName":"Brother"}},
  {"key":9144,"name":"Jiminy cricket","lastName":"cricket","gender":"Male","estimatedAge":60,"isDeceased":false,"parents":[{"personId":9140,"relationship":0,"relationshipLabel":"Son"},{"personId":9138,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"connectionDetails":{"connID":54519,"connectionTypeName":"Brother"}},
  {"key":9150,"name":"Ex Wife xu","lastName":"xu","gender":"Female","estimatedAge":null,"isDeceased":false,"parents":[],"enduringRelationships":[{"personId":9140,"status":2}],"connectionDetails":{"connID":54555,"connectionTypeName":"Adult","isFamilyMember":false}},
  {"key":9151,"name":"Ilya Xu","lastName":"Xu","gender":"Male","estimatedAge":1,"isDeceased":false,"parents":[{"personId":9150,"relationship":0,"relationshipLabel":"Son"},{"personId":9140,"relationship":0,"relationshipLabel":"Son"}],"enduringRelationships":[],"connectionDetails":{"connID":54573,"connectionTypeName":"Step Brother"}},
  {"key":9152,"name":"banjo Kazooie","lastName":"Kazooie","gender":"Female","estimatedAge":null,"isDeceased":false,"parents":[{"personId":9138,"relationship":0,"relationshipLabel":"Daughter"}],"enduringRelationships":[],"connectionDetails":{"connID":54576,"connectionTypeName":"Step Brother"}},
  {"key":9154,"name":"ex wife daughter","lastName":"(unknown)","gender":"Female","estimatedAge":null,"isDeceased":true,"parents":[{"personId":9138,"relationship":0,"relationshipLabel":"Daughter"}],"enduringRelationships":[],"connectionDetails":{"connID":54576,"connectionTypeName":"Step Brother"}},
  {"key":9136,"name":"MAry Cake","lastName":"Cake","gender":"Female","estimatedAge":null,"isDeceased":false,"parents":[{"personId":9138,"relationship":0,"relationshipLabel":"Daughter"},{"personId":9140,"relationship":0,"relationshipLabel":"Daughter"}],"enduringRelationships":[],"connectionDetails":{"connID":54476,"connectionTypeName":"Case Person","reporterType":null,"startDate":{"ts":1742342400000,"loc":{"locale":"en-US","numberingSystem":null,"outputCalendar":null,"intl":"en-US","weekdaysCache":{"format":{},"standalone":{}},"monthsCache":{"format":{},"standalone":{}},"meridiemCache":null,"eraCache":{},"specifiedLocale":null,"fastNumbersCached":null},"invalid":null,"weekData":null,"c":{"year":2025,"month":3,"day":19,"hour":0,"minute":0,"second":0,"millisecond":0},"o":0,"isLuxonDateTime":true},"isFamilyMember":false,"isCaseToPerson":true}},
  {"category":"MateLabel","key":-11},
  {"category":"MateLabel","key":-12},
  {"category":"MateLabel","key":-13}
  ],
  "modelData":{"proband" : 9136},
    "linkDataArray": [
  {"category":"Mate","from":9138,"to":9140,"labelKeys":[-11]},
  {"category":"Mate","from":9140,"to":9150,"labelKeys":[-13]},
  {"from":-11,"to":9137},
  {"from":-11,"to":9141},
  {"from":-11,"to":9144},
  {"from":-13,"to":9151},
  {"from":-11,"to":9136},
  {"from":9138,"to":9152},
  {"from":9140,"to":9153},
  {"from":9150,"to":9154}
  ]}
 </textarea>
</div>
</body>
</html>