Add interactive D3.js family tree visualization

Create new interactive visualization page with D3.js tree layout showing
biblical genealogies. Features include:
- Collapsible tree nodes to explore ancestors and descendants
- Zoom and pan controls for navigation
- Hover tooltips with person details
- Click to expand/collapse branches
- Double-click to navigate to person detail page

Also improve verse reference linking on person pages to handle various
formats more robustly. Add navigation link from main family tree page
to interactive visualization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 17:55:40 -05:00
parent 0a01e9d78e
commit ccbdceefa7
4 changed files with 591 additions and 13 deletions
+48
View File
@@ -1130,6 +1130,54 @@ def family_tree_page(request: Request):
)
@app.get("/family-tree/interactive", response_class=HTMLResponse)
def family_tree_interactive_page(request: Request):
"""Interactive D3.js visualization of biblical family tree"""
books = list(bible.iter_books())
# Load GEDCOM file from static folder
static_dir = Path(__file__).parent / "static"
gedcom_path = static_dir / "adameve.ged"
if not gedcom_path.exists():
raise HTTPException(
status_code=404,
detail=f"GEDCOM file not found. Please place 'adameve.ged' in the static folder."
)
if not GedcomReader:
raise HTTPException(
status_code=500,
detail="GEDCOM parser not available. Please install ged4py."
)
# Parse GEDCOM data
try:
family_tree_data, generations = parse_gedcom_to_tree_data(gedcom_path)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to parse GEDCOM file: {str(e)}"
)
# Convert family tree data to JSON for D3.js
family_tree_json = json.dumps(family_tree_data, default=str)
return templates.TemplateResponse(
"family_tree_interactive.html",
{
"request": request,
"books": books,
"family_tree_json": family_tree_json,
"breadcrumbs": [
{"text": "Home", "url": "/"},
{"text": "Family Tree", "url": "/family-tree"},
{"text": "Interactive", "url": None}
]
}
)
@app.get("/family-tree/generation/{gen_num}", response_class=HTMLResponse)
def family_tree_generation_page(request: Request, gen_num: int):
"""Individual generation page"""
+1
View File
@@ -92,6 +92,7 @@
<section>
<p>The Bible contains detailed genealogies that trace God's plan through specific family lines, culminating in the birth of Jesus Christ. This record spans from the creation of Adam through countless generations to the birth of our Lord.</p>
<p><a href="/family-tree/interactive">→ View Interactive Tree Visualization</a></p>
</section>
<section>
@@ -0,0 +1,519 @@
{% extends "base.html" %}
{% block title %}Interactive Family Tree - KJV Study{% endblock %}
{% block description %}Explore biblical genealogies through an interactive visualization from Adam to Jesus Christ.{% endblock %}
{% block head %}
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
.tree-intro {
max-width: 55%;
margin: 2rem 0;
}
.tree-controls {
max-width: 55%;
margin: 1.5rem 0;
padding: 1rem 0;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
.tree-controls button {
margin-right: 1rem;
padding: 0.5rem 1rem;
border: 1px solid #333;
background: white;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
}
.tree-controls button:hover {
background: #f5f5f5;
}
.tree-controls button:active {
background: #e5e5e5;
}
#tree-container {
width: 100%;
height: 800px;
border: 1px solid #ccc;
margin: 2rem 0;
overflow: hidden;
background: #fefefe;
}
.node circle {
fill: #fff;
stroke: #8b4513;
stroke-width: 2px;
cursor: pointer;
}
.node circle:hover {
stroke: #5a2d0c;
stroke-width: 3px;
}
.node.has-children circle {
fill: #f4f1ea;
}
.node text {
font-size: 12px;
font-family: et-book, Palatino, "Palatino Linotype", "Palatino LT STD", "Book Antiqua", Georgia, serif;
cursor: pointer;
}
.node text:hover {
font-weight: 600;
}
.link {
fill: none;
stroke: #999;
stroke-width: 1.5px;
}
.node.highlighted circle {
stroke: #c41e3a;
stroke-width: 3px;
fill: #fff5f5;
}
.tooltip {
position: absolute;
padding: 0.75rem;
background: white;
border: 1px solid #333;
border-radius: 3px;
pointer-events: none;
font-size: 0.9rem;
line-height: 1.6;
max-width: 250px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 1000;
}
.tooltip-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.tooltip-info {
color: #666;
font-size: 0.85rem;
}
.legend {
max-width: 55%;
margin: 1rem 0;
padding: 1rem 0;
border-top: 1px solid #ccc;
font-size: 0.95rem;
}
.legend-item {
display: inline-block;
margin-right: 2rem;
}
.legend-circle {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
margin-right: 0.5rem;
vertical-align: middle;
border: 2px solid #8b4513;
}
.navigation-links {
max-width: 55%;
margin: 2rem 0;
padding-top: 1rem;
border-top: 1px solid #ccc;
}
.navigation-links a {
margin-right: 1.5rem;
}
</style>
{% endblock %}
{% block content %}
<h1>Interactive Family Tree</h1>
<p class="subtitle">Biblical genealogies from Adam to Jesus Christ</p>
<section>
<div class="tree-intro">
<p>Explore the biblical genealogies through an interactive tree visualization. Click on any person to expand their descendants, or hover to see more details. The tree traces the lineage from Adam through the patriarchs, judges, kings, and ultimately to Jesus Christ.</p>
</div>
</section>
<div class="tree-controls">
<button id="reset-view">Reset View</button>
<button id="expand-all">Expand All</button>
<button id="collapse-all">Collapse All</button>
<button id="center-tree">Center</button>
</div>
<div class="legend">
<span class="legend-item">
<span class="legend-circle" style="background: white;"></span>
Person (click to expand)
</span>
<span class="legend-item">
<span class="legend-circle" style="background: #f4f1ea;"></span>
Has descendants
</span>
</div>
<div id="tree-container"></div>
<div class="navigation-links">
<a href="/family-tree">← Text-based Family Tree</a>
<a href="/family-tree/search">Search</a>
</div>
<script>
// Parse family tree data from the template
const familyTreeData = {{ family_tree_json | safe }};
// Transform the data into hierarchical structure for D3
function buildHierarchy(data) {
const people = { ...data };
// Find Adam (or the root person)
let root = null;
for (const [id, person] of Object.entries(people)) {
if (person.name === "Adam" || (person.parents && person.parents.length === 0 && person.generation === 1)) {
root = {
id: id,
name: person.name,
data: person,
children: []
};
break;
}
}
if (!root) {
// If no Adam, find the earliest generation
const sorted = Object.entries(people).sort((a, b) =>
(a[1].generation || 999) - (b[1].generation || 999)
);
if (sorted.length > 0) {
const [id, person] = sorted[0];
root = {
id: id,
name: person.name,
data: person,
children: []
};
}
}
// Build tree recursively
function addChildren(node) {
const person = people[node.id];
if (!person || !person.children || person.children.length === 0) {
return;
}
// Focus on primary lineage to keep tree manageable
// Prioritize children that have descendants
const childrenWithDescendants = person.children
.filter(childId => people[childId] && people[childId].children && people[childId].children.length > 0)
.slice(0, 3); // Limit to 3 main branches
const childrenIds = childrenWithDescendants.length > 0
? childrenWithDescendants
: person.children.slice(0, 5); // Show up to 5 if no descendants
for (const childId of childrenIds) {
const child = people[childId];
if (child) {
const childNode = {
id: childId,
name: child.name,
data: child,
children: []
};
node.children.push(childNode);
addChildren(childNode);
}
}
}
addChildren(root);
return root;
}
// Create the tree visualization
function createTree() {
const container = document.getElementById('tree-container');
const width = container.clientWidth;
const height = container.clientHeight;
const svg = d3.select("#tree-container")
.append("svg")
.attr("width", width)
.attr("height", height);
const g = svg.append("g")
.attr("transform", `translate(${width / 2},50)`);
// Create zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.1, 3])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
// Create tree layout
const treeLayout = d3.tree()
.size([width - 200, height - 200])
.separation((a, b) => (a.parent === b.parent ? 1 : 1.2));
// Build hierarchy
const hierarchyData = buildHierarchy(familyTreeData);
const root = d3.hierarchy(hierarchyData);
// Collapse all nodes initially except first few levels
root.children?.forEach(collapseRecursive);
function collapseRecursive(d, depth = 1) {
if (d.children && depth > 2) {
d._children = d.children;
d.children = null;
}
if (d.children) {
d.children.forEach(child => collapseRecursive(child, depth + 1));
}
}
root.x0 = 0;
root.y0 = 0;
// Create tooltip
const tooltip = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0)
.style("display", "none");
// Update function
function update(source) {
const duration = 400;
// Compute new tree layout
const treeData = treeLayout(root);
const nodes = treeData.descendants();
const links = treeData.links();
// Normalize for fixed-depth
nodes.forEach(d => {
d.y = d.depth * 180;
});
// Update nodes
const node = g.selectAll("g.node")
.data(nodes, d => d.id || (d.id = ++i));
// Enter new nodes
const nodeEnter = node.enter().append("g")
.attr("class", "node")
.attr("transform", d => `translate(${source.x0},${source.y0})`)
.on("click", click)
.on("mouseover", showTooltip)
.on("mouseout", hideTooltip);
nodeEnter.append("circle")
.attr("r", 6)
.style("fill", d => d._children ? "#f4f1ea" : "#fff");
nodeEnter.append("text")
.attr("dy", "-1em")
.attr("text-anchor", "middle")
.text(d => d.data.name)
.style("fill-opacity", 0);
// Update existing nodes
const nodeUpdate = nodeEnter.merge(node);
nodeUpdate.transition()
.duration(duration)
.attr("transform", d => `translate(${d.x},${d.y})`);
nodeUpdate.select("circle")
.attr("r", 6)
.attr("class", d => d._children || (d.children && d.children.length > 0) ? "has-children" : "")
.style("fill", d => d._children ? "#f4f1ea" : "#fff");
nodeUpdate.select("text")
.style("fill-opacity", 1);
// Remove exiting nodes
const nodeExit = node.exit().transition()
.duration(duration)
.attr("transform", d => `translate(${source.x},${source.y})`)
.remove();
nodeExit.select("circle")
.attr("r", 0);
nodeExit.select("text")
.style("fill-opacity", 0);
// Update links
const link = g.selectAll("path.link")
.data(links, d => d.target.id);
const linkEnter = link.enter().insert("path", "g")
.attr("class", "link")
.attr("d", d => {
const o = {x: source.x0, y: source.y0};
return diagonal(o, o);
});
const linkUpdate = linkEnter.merge(link);
linkUpdate.transition()
.duration(duration)
.attr("d", d => diagonal(d.source, d.target));
link.exit().transition()
.duration(duration)
.attr("d", d => {
const o = {x: source.x, y: source.y};
return diagonal(o, o);
})
.remove();
// Store old positions
nodes.forEach(d => {
d.x0 = d.x;
d.y0 = d.y;
});
}
// Create curved links
function diagonal(s, d) {
return `M ${s.x} ${s.y}
C ${s.x} ${(s.y + d.y) / 2},
${d.x} ${(s.y + d.y) / 2},
${d.x} ${d.y}`;
}
// Toggle children on click
function click(event, d) {
if (d.children) {
d._children = d.children;
d.children = null;
} else {
d.children = d._children;
d._children = null;
}
update(d);
// Also navigate to person page on double-click
if (event.detail === 2) {
window.location.href = `/family-tree/person/${d.data.id}`;
}
}
// Tooltip functions
function showTooltip(event, d) {
const person = d.data.data;
let html = `<div class="tooltip-name">${d.data.name}</div>`;
if (person.generation) {
html += `<div class="tooltip-info">Generation ${person.generation}</div>`;
}
if (person.age_at_death && person.age_at_death !== "Unknown") {
html += `<div class="tooltip-info">Lived ${person.age_at_death}</div>`;
}
if (person.children && person.children.length > 0) {
html += `<div class="tooltip-info">${person.children.length} children</div>`;
}
tooltip.html(html)
.style("display", "block")
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 10) + "px")
.transition()
.duration(200)
.style("opacity", 1);
}
function hideTooltip() {
tooltip.transition()
.duration(200)
.style("opacity", 0)
.on("end", () => tooltip.style("display", "none"));
}
// Control buttons
document.getElementById('reset-view').addEventListener('click', () => {
svg.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity.translate(width / 2, 50));
});
document.getElementById('expand-all').addEventListener('click', () => {
expandAll(root);
update(root);
});
document.getElementById('collapse-all').addEventListener('click', () => {
root.children?.forEach(collapseAll);
update(root);
});
document.getElementById('center-tree').addEventListener('click', () => {
const bounds = g.node().getBBox();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const scale = 0.8;
svg.transition()
.duration(750)
.call(zoom.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-centerX, -centerY)
);
});
function expandAll(d) {
if (d._children) {
d.children = d._children;
d._children = null;
}
if (d.children) {
d.children.forEach(expandAll);
}
}
function collapseAll(d) {
if (d.children) {
d._children = d.children;
d.children = null;
d._children.forEach(collapseAll);
}
}
// Initial tree draw
let i = 0;
update(root);
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', createTree);
</script>
{% endblock %}
+23 -13
View File
@@ -179,27 +179,37 @@
{% for verse in person.verses %}
<div class="verse-entry">
<div class="verse-ref">
{% set ref_parts = verse.reference.split(' ') %}
{% if ref_parts|length >= 2 %}
{% set chapter_verse = ref_parts[-1] %}
{% if ':' in chapter_verse %}
{% set chapter = chapter_verse.split(':')[0] %}
{% set verse_part = chapter_verse.split(':')[1] %}
{% if '-' in verse_part %}
{% set verse_num = verse_part.split('-')[0] %}
{% if verse.reference %}
{% set ref_parts = verse.reference.split(' ') %}
{% if ref_parts|length >= 2 %}
{% set chapter_verse = ref_parts[-1] %}
{% if ':' in chapter_verse %}
{% set chapter = chapter_verse.split(':')[0] %}
{% set verse_part = chapter_verse.split(':')[1] %}
{% if '-' in verse_part %}
{% set verse_num = verse_part.split('-')[0] %}
{% else %}
{% set verse_num = verse_part %}
{% endif %}
{% set book = ' '.join(ref_parts[:-1]) %}
<a href="/book/{{ book }}/chapter/{{ chapter }}/verse/{{ verse_num }}">{{ verse.reference }}</a>
{% else %}
{% set verse_num = verse_part %}
{% set book = ' '.join(ref_parts[:-1]) %}
{% set chapter = ref_parts[-1] %}
<a href="/book/{{ book }}/chapter/{{ chapter }}">{{ verse.reference }}</a>
{% endif %}
{% set book = ' '.join(ref_parts[:-1]) %}
<a href="/book/{{ book }}/chapter/{{ chapter }}/verse/{{ verse_num }}">{{ verse.reference }}</a>
{% else %}
{{ verse.reference }}
{{ verse.reference if verse.reference else verse }}
{% endif %}
{% elif verse is string %}
{{ verse }}
{% else %}
{{ verse.reference }}
{{ verse.text if verse.text else '' }}
{% endif %}
</div>
{% if verse.text %}
<div class="verse-text">{{ verse.text }}</div>
{% endif %}
</div>
{% endfor %}
</div>