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>