Transform family tree into Obsidian-style force graph

Replace hierarchical tree with force-directed graph visualization:
- Dark theme with radial gradient background (Obsidian-inspired)
- Physics-based node positioning with adjustable forces
- Draggable nodes with collision detection
- Click to highlight connections, double-click to navigate
- Color-coded nodes by generation (early to late)
- Notable figures (Adam, Noah, Abraham, David, Jesus, etc.) highlighted
- Interactive controls: pause/resume, reset positions, adjust forces
- Sliders for charge strength and link distance
- Smooth animations and glow effects on hover
- Stats overlay showing node count and simulation energy
- Organic, nebulous layout like Obsidian's graph view

🤖 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:58:23 -05:00
parent ccbdceefa7
commit c9ac954edf
@@ -1,7 +1,7 @@
{% 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 description %}Explore biblical genealogies through an interactive graph visualization from Adam to Jesus Christ.{% endblock %}
{% block head %}
<script src="https://d3js.org/d3.v7.min.js"></script>
@@ -37,74 +37,122 @@
background: #e5e5e5;
}
.tree-controls label {
margin-right: 1rem;
font-size: 0.95rem;
}
.tree-controls input[type="range"] {
width: 150px;
vertical-align: middle;
}
#tree-container {
width: 100%;
height: 800px;
border: 1px solid #ccc;
border: 1px solid #2a2a2a;
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;
background: #1e1e1e;
background: radial-gradient(ellipse at center, #242424 0%, #1a1a1a 100%);
}
.link {
fill: none;
stroke: #999;
stroke: #4a4a4a;
stroke-opacity: 0.6;
stroke-width: 1.5px;
fill: none;
}
.node.highlighted circle {
stroke: #c41e3a;
.link.parent-child {
stroke: #6b4423;
stroke-opacity: 0.7;
}
.link.highlighted {
stroke: #8b6f47;
stroke-opacity: 0.9;
stroke-width: 2.5px;
}
.node circle {
stroke: #8b7355;
stroke-width: 2px;
cursor: pointer;
transition: all 0.3s ease;
}
.node circle:hover {
stroke: #b8956a;
stroke-width: 3px;
fill: #fff5f5;
filter: drop-shadow(0 0 8px rgba(139, 115, 85, 0.6));
}
.node.important circle {
fill: #6b4423;
stroke: #9d6b3d;
stroke-width: 2.5px;
}
.node.generation-1 circle {
fill: #4a3728;
}
.node.generation-2-10 circle {
fill: #5a4535;
}
.node.generation-11-20 circle {
fill: #6b5442;
}
.node.generation-20plus circle {
fill: #7a6350;
}
.node text {
font-size: 11px;
font-family: et-book, Palatino, "Palatino Linotype", Georgia, serif;
fill: #c9c9c9;
pointer-events: none;
text-shadow: 0 0 3px rgba(0,0,0,0.8), 0 0 5px rgba(0,0,0,0.5);
}
.node.important text {
font-weight: 600;
font-size: 13px;
fill: #e6d5c3;
}
.node.dragging circle {
stroke: #d4a574;
stroke-width: 4px;
}
.tooltip {
position: absolute;
padding: 0.75rem;
background: white;
border: 1px solid #333;
border-radius: 3px;
background: rgba(30, 30, 30, 0.95);
border: 1px solid #5a5a5a;
border-radius: 4px;
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);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 1000;
color: #d9d9d9;
backdrop-filter: blur(4px);
}
.tooltip-name {
font-weight: 600;
margin-bottom: 0.25rem;
color: #e6d5c3;
}
.tooltip-info {
color: #666;
color: #b0b0b0;
font-size: 0.85rem;
}
@@ -128,7 +176,7 @@
border-radius: 50%;
margin-right: 0.5rem;
vertical-align: middle;
border: 2px solid #8b4513;
border: 2px solid #8b7355;
}
.navigation-links {
@@ -141,38 +189,64 @@
.navigation-links a {
margin-right: 1.5rem;
}
.stats-overlay {
position: absolute;
top: 10px;
right: 10px;
padding: 0.5rem 1rem;
background: rgba(30, 30, 30, 0.8);
border: 1px solid #4a4a4a;
border-radius: 4px;
color: #b0b0b0;
font-size: 0.85rem;
pointer-events: none;
}
</style>
{% endblock %}
{% block content %}
<h1>Interactive Family Tree</h1>
<p class="subtitle">Biblical genealogies from Adam to Jesus Christ</p>
<p class="subtitle">Biblical genealogies as a connected graph</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>
<p>Explore biblical genealogies through a force-directed graph visualization. Drag nodes to rearrange, hover for details, click to highlight connections. The graph shows relationships from Adam through the patriarchs 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>
<button id="reset-positions">Reset Positions</button>
<button id="pause-simulation">Pause</button>
<button id="center-view">Center View</button>
<label>
Charge Strength:
<input type="range" id="charge-slider" min="-500" max="-50" value="-200" step="10">
</label>
<label>
Link Distance:
<input type="range" id="link-slider" min="30" max="150" value="80" step="5">
</label>
</div>
<div class="legend">
<span class="legend-item">
<span class="legend-circle" style="background: white;"></span>
Person (click to expand)
<span class="legend-circle" style="background: #4a3728;"></span>
Early Generations
</span>
<span class="legend-item">
<span class="legend-circle" style="background: #f4f1ea;"></span>
Has descendants
<span class="legend-circle" style="background: #6b4423;"></span>
Notable Figures
</span>
<span class="legend-item">
<span class="legend-circle" style="background: #7a6350;"></span>
Later Generations
</span>
</div>
<div id="tree-container"></div>
<div id="tree-container">
<div class="stats-overlay" id="stats"></div>
</div>
<div class="navigation-links">
<a href="/family-tree">← Text-based Family Tree</a>
@@ -183,78 +257,53 @@
// 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 };
// Notable biblical figures
const notableFigures = new Set([
"Adam", "Noah", "Abraham", "Isaac", "Jacob", "Joseph", "Moses",
"David", "Solomon", "Isaiah", "Jeremiah", "Daniel", "Jesus",
"Ruth", "Esther", "Mary", "Sarah"
]);
// 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;
}
// Build graph data structure
function buildGraphData(data) {
const nodes = [];
const links = [];
const nodeMap = new Map();
// Create nodes
for (const [id, person] of Object.entries(data)) {
const node = {
id: id,
name: person.name,
generation: person.generation || 0,
children: person.children || [],
parents: person.parents || [],
age: person.age_at_death,
spouse: person.spouse,
isNotable: notableFigures.has(person.name)
};
nodes.push(node);
nodeMap.set(id, node);
}
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);
// Create links (parent-child relationships)
for (const node of nodes) {
for (const childId of node.children) {
if (nodeMap.has(childId)) {
links.push({
source: node.id,
target: childId,
type: 'parent-child'
});
}
}
}
addChildren(root);
return root;
return { nodes, links };
}
// Create the tree visualization
function createTree() {
// Create the force-directed graph
function createGraph() {
const container = document.getElementById('tree-container');
const width = container.clientWidth;
const height = container.clientHeight;
@@ -264,41 +313,19 @@ function createTree() {
.attr("width", width)
.attr("height", height);
const g = svg.append("g")
.attr("transform", `translate(${width / 2},50)`);
const g = svg.append("g");
// Create zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.1, 3])
.scaleExtent([0.1, 4])
.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;
// Build graph data
const graphData = buildGraphData(familyTreeData);
// Create tooltip
const tooltip = d3.select("body").append("div")
@@ -306,139 +333,153 @@ function createTree() {
.style("opacity", 0)
.style("display", "none");
// Update function
function update(source) {
const duration = 400;
// Create force simulation
let chargeStrength = -200;
let linkDistance = 80;
// Compute new tree layout
const treeData = treeLayout(root);
const nodes = treeData.descendants();
const links = treeData.links();
const simulation = d3.forceSimulation(graphData.nodes)
.force("link", d3.forceLink(graphData.links)
.id(d => d.id)
.distance(linkDistance)
.strength(1))
.force("charge", d3.forceManyBody()
.strength(chargeStrength))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide()
.radius(d => d.isNotable ? 25 : 20));
// Normalize for fixed-depth
nodes.forEach(d => {
d.y = d.depth * 180;
// Create links
const link = g.append("g")
.selectAll("path")
.data(graphData.links)
.enter()
.append("path")
.attr("class", d => `link ${d.type}`);
// Create nodes
const node = g.append("g")
.selectAll("g")
.data(graphData.nodes)
.enter()
.append("g")
.attr("class", d => {
let classes = "node";
if (d.isNotable) classes += " important";
if (d.generation === 1) classes += " generation-1";
else if (d.generation <= 10) classes += " generation-2-10";
else if (d.generation <= 20) classes += " generation-11-20";
else classes += " generation-20plus";
return classes;
})
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended))
.on("click", clicked)
.on("mouseover", showTooltip)
.on("mouseout", hideTooltip);
// Add circles to nodes
node.append("circle")
.attr("r", d => {
if (d.isNotable) return 8;
const childCount = d.children.length;
return Math.max(4, Math.min(7, 4 + childCount * 0.3));
});
// Update nodes
const node = g.selectAll("g.node")
.data(nodes, d => d.id || (d.id = ++i));
// Add labels to nodes
node.append("text")
.attr("dx", 10)
.attr("dy", 3)
.text(d => d.name);
// 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;
// Update positions on each tick
simulation.on("tick", () => {
link.attr("d", d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
return `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`;
});
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
// Update stats overlay
function updateStats() {
const alpha = simulation.alpha().toFixed(3);
document.getElementById('stats').innerHTML =
`Nodes: ${graphData.nodes.length} | Links: ${graphData.links.length} | Energy: ${alpha}`;
}
// 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}`;
simulation.on("tick", updateStats);
// Drag functions
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
d3.select(this).classed("dragging", true);
}
// Toggle children on click
function click(event, d) {
if (d.children) {
d._children = d.children;
d.children = null;
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
d3.select(this).classed("dragging", false);
}
// Click to highlight connections
let selectedNode = null;
function clicked(event, d) {
event.stopPropagation();
// Reset previous selection
link.classed("highlighted", false);
if (selectedNode === d) {
// Deselect
selectedNode = null;
} else {
d.children = d._children;
d._children = null;
}
update(d);
// Select and highlight connections
selectedNode = d;
// Also navigate to person page on double-click
link.classed("highlighted", link_d =>
link_d.source.id === d.id || link_d.target.id === d.id
);
}
// Double click to navigate
if (event.detail === 2) {
window.location.href = `/family-tree/person/${d.data.id}`;
window.location.href = `/family-tree/person/${d.id}`;
}
}
// Click background to deselect
svg.on("click", () => {
selectedNode = null;
link.classed("highlighted", false);
});
// Tooltip functions
function showTooltip(event, d) {
const person = d.data.data;
let html = `<div class="tooltip-name">${d.data.name}</div>`;
let html = `<div class="tooltip-name">${d.name}</div>`;
if (person.generation) {
html += `<div class="tooltip-info">Generation ${person.generation}</div>`;
if (d.generation) {
html += `<div class="tooltip-info">Generation ${d.generation}</div>`;
}
if (person.age_at_death && person.age_at_death !== "Unknown") {
html += `<div class="tooltip-info">Lived ${person.age_at_death}</div>`;
if (d.age && d.age !== "Unknown") {
html += `<div class="tooltip-info">Lived ${d.age}</div>`;
}
if (person.children && person.children.length > 0) {
html += `<div class="tooltip-info">${person.children.length} children</div>`;
if (d.children && d.children.length > 0) {
html += `<div class="tooltip-info">${d.children.length} children</div>`;
}
if (d.spouse) {
html += `<div class="tooltip-info">Spouse: ${d.spouse}</div>`;
}
tooltip.html(html)
@@ -458,62 +499,64 @@ function createTree() {
}
// Control buttons
document.getElementById('reset-view').addEventListener('click', () => {
let isPaused = false;
document.getElementById('pause-simulation').addEventListener('click', function() {
if (isPaused) {
simulation.restart();
this.textContent = 'Pause';
} else {
simulation.stop();
this.textContent = 'Resume';
}
isPaused = !isPaused;
});
document.getElementById('reset-positions').addEventListener('click', () => {
graphData.nodes.forEach(d => {
d.fx = null;
d.fy = null;
});
simulation.alpha(1).restart();
});
document.getElementById('center-view').addEventListener('click', () => {
svg.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity.translate(width / 2, 50));
.call(zoom.transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(1));
});
document.getElementById('expand-all').addEventListener('click', () => {
expandAll(root);
update(root);
// Charge strength slider
document.getElementById('charge-slider').addEventListener('input', function() {
chargeStrength = +this.value;
simulation.force("charge").strength(chargeStrength);
simulation.alpha(0.3).restart();
});
document.getElementById('collapse-all').addEventListener('click', () => {
root.children?.forEach(collapseAll);
update(root);
// Link distance slider
document.getElementById('link-slider').addEventListener('input', function() {
linkDistance = +this.value;
simulation.force("link").distance(linkDistance);
simulation.alpha(0.3).restart();
});
document.getElementById('center-tree').addEventListener('click', () => {
// Initial zoom to fit
setTimeout(() => {
const bounds = g.node().getBBox();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const scale = 0.8;
const fullWidth = bounds.width;
const fullHeight = bounds.height;
const midX = bounds.x + fullWidth / 2;
const midY = bounds.y + fullHeight / 2;
const scale = 0.9 / Math.max(fullWidth / width, fullHeight / height);
const translate = [width / 2 - scale * midX, height / 2 - scale * midY];
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);
.call(zoom.transform, d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale));
}, 1000);
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', createTree);
document.addEventListener('DOMContentLoaded', createGraph);
</script>
{% endblock %}