mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
Show parents above selected person in interactive family tree
- Add buildBidirectionalTreeData function to build both ancestors and descendants - Render ancestors above the selected person (flipped tree) - Render descendants below as before - Shows up to 2 generations of ancestors and configurable descendants - Double-click any node to re-center the tree on that person 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -806,6 +806,7 @@ function buildTreeData(rootId, maxDepth, direction = 'descendants') {
|
||||
spouse: person.spouse,
|
||||
gender: inferGender(person.name),
|
||||
hasChildren: relatedIds.length > 0,
|
||||
hasParents: (person.parents || []).length > 0,
|
||||
kekule_number: person.kekule_number
|
||||
},
|
||||
children: []
|
||||
@@ -823,6 +824,119 @@ function buildTreeData(rootId, maxDepth, direction = 'descendants') {
|
||||
return buildNode(rootId, 0);
|
||||
}
|
||||
|
||||
// Build bidirectional tree - parents above, children below
|
||||
function buildBidirectionalTreeData(rootId, descendantDepth = 3, ancestorDepth = 2) {
|
||||
const person = familyTreeData[rootId];
|
||||
if (!person) return null;
|
||||
|
||||
// Build ancestors (going up)
|
||||
function buildAncestors(personId, depth) {
|
||||
if (depth > ancestorDepth) return null;
|
||||
|
||||
const p = familyTreeData[personId];
|
||||
if (!p) return null;
|
||||
|
||||
const parentIds = p.parents || [];
|
||||
|
||||
const node = {
|
||||
id: personId,
|
||||
data: {
|
||||
name: p.name,
|
||||
birth_year: p.birth_year,
|
||||
death_year: p.death_year,
|
||||
age_at_death: p.age_at_death,
|
||||
generation: p.generation,
|
||||
spouse: p.spouse,
|
||||
gender: inferGender(p.name),
|
||||
hasChildren: (p.children || []).length > 0,
|
||||
hasParents: parentIds.length > 0,
|
||||
kekule_number: p.kekule_number,
|
||||
isAncestor: depth > 0
|
||||
},
|
||||
children: []
|
||||
};
|
||||
|
||||
// For ancestors, "children" in the tree are actually parents
|
||||
for (const parentId of parentIds) {
|
||||
const parentNode = buildAncestors(parentId, depth + 1);
|
||||
if (parentNode) node.children.push(parentNode);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// Build descendants (going down)
|
||||
function buildDescendants(personId, depth, visited = new Set()) {
|
||||
if (depth > descendantDepth || visited.has(personId)) return null;
|
||||
visited.add(personId);
|
||||
|
||||
const p = familyTreeData[personId];
|
||||
if (!p) return null;
|
||||
|
||||
const childIds = p.children || [];
|
||||
|
||||
const node = {
|
||||
id: personId,
|
||||
data: {
|
||||
name: p.name,
|
||||
birth_year: p.birth_year,
|
||||
death_year: p.death_year,
|
||||
age_at_death: p.age_at_death,
|
||||
generation: p.generation,
|
||||
spouse: p.spouse,
|
||||
gender: inferGender(p.name),
|
||||
hasChildren: childIds.length > 0,
|
||||
hasParents: (p.parents || []).length > 0,
|
||||
kekule_number: p.kekule_number,
|
||||
isDescendant: depth > 0
|
||||
},
|
||||
children: []
|
||||
};
|
||||
|
||||
for (const childId of childIds) {
|
||||
const childNode = buildDescendants(childId, depth + 1, visited);
|
||||
if (childNode) node.children.push(childNode);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// Get the main person's data
|
||||
const rootNode = {
|
||||
id: rootId,
|
||||
data: {
|
||||
name: person.name,
|
||||
birth_year: person.birth_year,
|
||||
death_year: person.death_year,
|
||||
age_at_death: person.age_at_death,
|
||||
generation: person.generation,
|
||||
spouse: person.spouse,
|
||||
gender: inferGender(person.name),
|
||||
hasChildren: (person.children || []).length > 0,
|
||||
hasParents: (person.parents || []).length > 0,
|
||||
kekule_number: person.kekule_number,
|
||||
isRoot: true
|
||||
},
|
||||
children: [],
|
||||
_ancestors: []
|
||||
};
|
||||
|
||||
// Build descendants
|
||||
const visited = new Set([rootId]);
|
||||
for (const childId of (person.children || [])) {
|
||||
const childNode = buildDescendants(childId, 1, visited);
|
||||
if (childNode) rootNode.children.push(childNode);
|
||||
}
|
||||
|
||||
// Build ancestors separately (we'll render them in a second tree above)
|
||||
for (const parentId of (person.parents || [])) {
|
||||
const ancestorNode = buildAncestors(parentId, 1);
|
||||
if (ancestorNode) rootNode._ancestors.push(ancestorNode);
|
||||
}
|
||||
|
||||
return rootNode;
|
||||
}
|
||||
|
||||
// Toggle node expand/collapse
|
||||
function toggleNode(d) {
|
||||
if (d.children) {
|
||||
@@ -1343,11 +1457,11 @@ function renderTree() {
|
||||
fitToView();
|
||||
}
|
||||
|
||||
// Re-render tree from a specific person ID
|
||||
// Re-render tree from a specific person ID (bidirectional - shows parents and children)
|
||||
function renderTreeFromId(personId) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const treeData = buildTreeData(personId, currentDepth, currentDirection);
|
||||
const treeData = buildBidirectionalTreeData(personId, currentDepth, 2);
|
||||
if (!treeData) {
|
||||
container.innerHTML = '<div class="tree-loading">Could not build tree</div>';
|
||||
return;
|
||||
@@ -1374,23 +1488,89 @@ function renderTreeFromId(personId) {
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Create hierarchy
|
||||
treeRoot = d3.hierarchy(treeData);
|
||||
|
||||
// Calculate tree dimensions
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 80;
|
||||
const nodeHeight = 100;
|
||||
const isHorizontal = currentOrientation === 'left-right';
|
||||
|
||||
// Tree layout
|
||||
const treeLayout = d3.tree()
|
||||
// We'll render two trees: ancestors above and descendants below
|
||||
// First, layout descendants tree
|
||||
const descendantData = {
|
||||
id: treeData.id,
|
||||
data: treeData.data,
|
||||
children: treeData.children
|
||||
};
|
||||
|
||||
treeRoot = d3.hierarchy(descendantData);
|
||||
const descendantLayout = d3.tree()
|
||||
.nodeSize(isHorizontal ? [nodeHeight, nodeWidth] : [nodeWidth, nodeHeight]);
|
||||
descendantLayout(treeRoot);
|
||||
|
||||
treeLayout(treeRoot);
|
||||
// Position descendants below center
|
||||
const rootY = isHorizontal ? treeRoot.y : treeRoot.y;
|
||||
|
||||
// Center the tree
|
||||
// Collect all nodes and links
|
||||
let allNodes = [...treeRoot.descendants()];
|
||||
let allLinks = [...treeRoot.links()];
|
||||
|
||||
// Now handle ancestors if any
|
||||
if (treeData._ancestors && treeData._ancestors.length > 0) {
|
||||
// Create a "fake root" that is the selected person, with ancestors as children
|
||||
const ancestorRootData = {
|
||||
id: treeData.id + '_ancestor_root',
|
||||
data: { ...treeData.data, isAncestorRoot: true },
|
||||
children: treeData._ancestors
|
||||
};
|
||||
|
||||
const ancestorRoot = d3.hierarchy(ancestorRootData);
|
||||
const ancestorLayout = d3.tree()
|
||||
.nodeSize(isHorizontal ? [nodeHeight, nodeWidth] : [nodeWidth, nodeHeight]);
|
||||
ancestorLayout(ancestorRoot);
|
||||
|
||||
// Flip and offset ancestor positions (they go up instead of down)
|
||||
ancestorRoot.descendants().forEach(d => {
|
||||
if (isHorizontal) {
|
||||
d.y = -d.y; // Flip horizontally (to the left)
|
||||
} else {
|
||||
d.y = -d.y; // Flip vertically (upward)
|
||||
}
|
||||
});
|
||||
|
||||
// Skip the fake root node (it's the same person as the main root)
|
||||
const ancestorNodes = ancestorRoot.descendants().filter(d => !d.data.data.isAncestorRoot);
|
||||
|
||||
// Create links from main root to immediate ancestors
|
||||
const ancestorLinks = [];
|
||||
for (const ancestor of ancestorRoot.children || []) {
|
||||
ancestorLinks.push({
|
||||
source: treeRoot, // Main person
|
||||
target: ancestor
|
||||
});
|
||||
// Add the rest of the ancestor links
|
||||
if (ancestor.links) {
|
||||
ancestorLinks.push(...ancestor.links());
|
||||
}
|
||||
}
|
||||
|
||||
// Also get links within ancestor tree (grandparents etc)
|
||||
ancestorRoot.links().forEach(link => {
|
||||
if (!link.source.data.data.isAncestorRoot) {
|
||||
ancestorLinks.push(link);
|
||||
} else if (link.target) {
|
||||
// Link from root to immediate parent - redirect to main treeRoot
|
||||
ancestorLinks.push({
|
||||
source: treeRoot,
|
||||
target: link.target
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
allNodes = [...allNodes, ...ancestorNodes];
|
||||
allLinks = [...allLinks, ...ancestorLinks];
|
||||
}
|
||||
|
||||
// Calculate bounds for centering
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
treeRoot.descendants().forEach(d => {
|
||||
allNodes.forEach(d => {
|
||||
const x = isHorizontal ? d.y : d.x;
|
||||
const y = isHorizontal ? d.x : d.y;
|
||||
minX = Math.min(minX, x);
|
||||
@@ -1409,7 +1589,7 @@ function renderTreeFromId(personId) {
|
||||
: d3.linkVertical().x(d => d.x).y(d => d.y);
|
||||
|
||||
g.selectAll('.link')
|
||||
.data(treeRoot.links())
|
||||
.data(allLinks)
|
||||
.join('path')
|
||||
.attr('class', 'link')
|
||||
.attr('fill', 'none')
|
||||
@@ -1419,7 +1599,7 @@ function renderTreeFromId(personId) {
|
||||
|
||||
// Draw nodes
|
||||
const nodes = g.selectAll('.node')
|
||||
.data(treeRoot.descendants())
|
||||
.data(allNodes)
|
||||
.join('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', d => {
|
||||
@@ -1434,16 +1614,10 @@ function renderTreeFromId(personId) {
|
||||
this.appendChild(card);
|
||||
});
|
||||
|
||||
// Single click: expand/collapse
|
||||
// Single click: show info
|
||||
nodes.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
const canToggle = d.children || d._children ||
|
||||
(d.data.data && d.data.data.hasChildren);
|
||||
if (canToggle) {
|
||||
toggleNode(d);
|
||||
} else {
|
||||
showPersonInfo(d.data);
|
||||
}
|
||||
showPersonInfo(d.data);
|
||||
});
|
||||
|
||||
// Double click: re-center
|
||||
|
||||
Reference in New Issue
Block a user