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