mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
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:
@@ -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"""
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user