diff --git a/kjvstudy_org/templates/family_tree_interactive.html b/kjvstudy_org/templates/family_tree_interactive.html
index ef5d4c4..e60eac0 100644
--- a/kjvstudy_org/templates/family_tree_interactive.html
+++ b/kjvstudy_org/templates/family_tree_interactive.html
@@ -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 %}
@@ -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;
+}
{% endblock %}
{% block content %}
← Text-based Family Tree
@@ -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 = `
${d.data.name}
`;
+ let html = `
${d.name}
`;
- if (person.generation) {
- html += `
Generation ${person.generation}
`;
+ if (d.generation) {
+ html += `
Generation ${d.generation}
`;
}
- if (person.age_at_death && person.age_at_death !== "Unknown") {
- html += `
Lived ${person.age_at_death}
`;
+ if (d.age && d.age !== "Unknown") {
+ html += `
Lived ${d.age}
`;
}
- if (person.children && person.children.length > 0) {
- html += `
${person.children.length} children
`;
+ if (d.children && d.children.length > 0) {
+ html += `
${d.children.length} children
`;
+ }
+ if (d.spouse) {
+ html += `
Spouse: ${d.spouse}
`;
}
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);
{% endblock %}