mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-19 06:00:57 +00:00
f38f88ed5d
- Add golden/yellow highlight for Kekulé ancestors (blood relations to Christ) - Add search autocomplete for finding people quickly - Implement back button navigation with history tracking - Add more Messianic lineage starting points (Seth, Enoch, Shem, Isaac, Jacob, Judah, Solomon, Josiah, Joseph) - Set vertical layout as default - Default to 3 generations for cleaner view - Adjust zoom for better readability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1802 lines
52 KiB
HTML
1802 lines
52 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Interactive Family Tree - KJV Study{% endblock %}
|
||
{% block description %}Explore biblical genealogies interactively from Adam to Jesus Christ.{% endblock %}
|
||
|
||
{% block head %}
|
||
<style>
|
||
/* Main container */
|
||
.tree-page {
|
||
max-width: 100%;
|
||
padding: 0;
|
||
}
|
||
|
||
.tree-header {
|
||
max-width: 55%;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.tree-header h1 {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.tree-header p {
|
||
color: #666;
|
||
margin: 0;
|
||
}
|
||
|
||
/* Controls bar */
|
||
.tree-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
padding: 0.75rem 1rem;
|
||
background: linear-gradient(to bottom, #fafafa, #f0f0f0);
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
margin-bottom: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.control-group label {
|
||
font-size: 0.9rem;
|
||
color: #555;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.control-group select {
|
||
font-family: inherit;
|
||
font-size: 0.9rem;
|
||
padding: 0.4rem 0.6rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
background: white;
|
||
}
|
||
|
||
.control-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
background: white;
|
||
cursor: pointer;
|
||
font-size: 1.1rem;
|
||
color: #555;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
background: #f5f5f5;
|
||
border-color: #999;
|
||
}
|
||
|
||
.control-btn:active {
|
||
background: #eee;
|
||
}
|
||
|
||
.control-divider {
|
||
width: 1px;
|
||
height: 24px;
|
||
background: #ddd;
|
||
margin: 0 0.5rem;
|
||
}
|
||
|
||
/* Tree viewport */
|
||
.tree-viewport {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 70vh;
|
||
min-height: 500px;
|
||
background: #fcfcfc;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#family-tree-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* Person cards - FamilySearch inspired */
|
||
.f3 .card {
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
|
||
.f3 .card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.person-card {
|
||
background: white;
|
||
border: 2px solid #4a7c59;
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
min-width: 160px;
|
||
max-width: 200px;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||
font-family: "ETBembo", Palatino, "Book Antiqua", serif;
|
||
}
|
||
|
||
.person-card.male {
|
||
border-color: #3b6ea5;
|
||
background: linear-gradient(to bottom, #f8fbff, #fff);
|
||
}
|
||
|
||
.person-card.female {
|
||
border-color: #a55b80;
|
||
background: linear-gradient(to bottom, #fff8fb, #fff);
|
||
}
|
||
|
||
/* Kekulé number highlight - blood relation to Christ */
|
||
.person-card.kekule {
|
||
border-color: #d4af37 !important;
|
||
background: linear-gradient(to bottom, #fffde7, #fff9e6) !important;
|
||
box-shadow: 0 0 8px rgba(212, 175, 55, 0.4);
|
||
}
|
||
|
||
.person-card.kekule.male {
|
||
border-color: #d4af37 !important;
|
||
background: linear-gradient(to bottom, #fffde7, #f8fbff) !important;
|
||
}
|
||
|
||
.person-card.kekule.female {
|
||
border-color: #d4af37 !important;
|
||
background: linear-gradient(to bottom, #fffde7, #fff8fb) !important;
|
||
}
|
||
|
||
.person-card .card-name {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: #222;
|
||
margin-bottom: 4px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.person-card .card-dates {
|
||
font-size: 0.8rem;
|
||
color: #666;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.person-card .card-meta {
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Connection lines */
|
||
.f3 .link {
|
||
fill: none;
|
||
stroke: #999;
|
||
stroke-width: 2px;
|
||
}
|
||
|
||
/* Info sidebar */
|
||
.info-sidebar {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 280px;
|
||
height: 100%;
|
||
background: white;
|
||
border-left: 1px solid #ddd;
|
||
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
|
||
transform: translateX(100%);
|
||
transition: transform 0.3s ease;
|
||
overflow-y: auto;
|
||
z-index: 100;
|
||
}
|
||
|
||
.info-sidebar.open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.info-sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 1rem;
|
||
border-bottom: 1px solid #eee;
|
||
background: #f9f9f9;
|
||
}
|
||
|
||
.info-sidebar-header h3 {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.info-sidebar-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
color: #666;
|
||
line-height: 1;
|
||
padding: 0;
|
||
}
|
||
|
||
.info-sidebar-content {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.info-field {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.info-field-label {
|
||
font-size: 0.75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: #888;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.info-field-value {
|
||
font-size: 1rem;
|
||
color: #333;
|
||
}
|
||
|
||
.info-field-value a {
|
||
color: #3b6ea5;
|
||
}
|
||
|
||
.info-actions {
|
||
margin-top: 1.5rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
.info-actions a {
|
||
display: block;
|
||
padding: 0.6rem 1rem;
|
||
background: #4a7c59;
|
||
color: white;
|
||
text-align: center;
|
||
border-radius: 4px;
|
||
text-decoration: none;
|
||
font-weight: 500;
|
||
transition: background 0.15s ease;
|
||
}
|
||
|
||
.info-actions a:hover {
|
||
background: #3d6a4b;
|
||
}
|
||
|
||
/* Navigation hint */
|
||
.tree-hint {
|
||
position: absolute;
|
||
bottom: 1rem;
|
||
left: 1rem;
|
||
padding: 0.5rem 1rem;
|
||
background: rgba(255,255,255,0.95);
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 0.85rem;
|
||
color: #666;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
z-index: 50;
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
.tree-controls {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.control-divider {
|
||
display: none;
|
||
}
|
||
|
||
.info-sidebar {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
/* Loading state */
|
||
.tree-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: #666;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
/* Back link */
|
||
.back-link {
|
||
margin-top: 1.5rem;
|
||
}
|
||
|
||
/* Navigation history breadcrumb */
|
||
.tree-breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.5rem 1rem;
|
||
background: #f5f5f5;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 0.9rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.breadcrumb-btn {
|
||
background: none;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
padding: 0.25rem 0.5rem;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 0.85rem;
|
||
color: #555;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.breadcrumb-btn:hover {
|
||
background: #e8e8e8;
|
||
border-color: #999;
|
||
}
|
||
|
||
.breadcrumb-btn.back-btn {
|
||
background: #4a7c59;
|
||
color: white;
|
||
border-color: #4a7c59;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.breadcrumb-btn.back-btn:hover {
|
||
background: #3d6a4b;
|
||
}
|
||
|
||
.breadcrumb-btn.back-btn:disabled {
|
||
background: #ccc;
|
||
border-color: #ccc;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.breadcrumb-sep {
|
||
color: #999;
|
||
}
|
||
|
||
.breadcrumb-current {
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
/* Search input in controls */
|
||
.search-group {
|
||
flex: 1;
|
||
min-width: 175px;
|
||
max-width: 250px;
|
||
}
|
||
|
||
.search-wrapper {
|
||
position: relative;
|
||
flex: 1;
|
||
}
|
||
|
||
.search-wrapper input {
|
||
width: 100%;
|
||
padding: 0.4rem 0.6rem;
|
||
font-family: inherit;
|
||
font-size: 0.9rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.search-wrapper input:focus {
|
||
outline: none;
|
||
border-color: #4a7c59;
|
||
box-shadow: 0 0 3px rgba(74, 124, 89, 0.3);
|
||
}
|
||
|
||
.search-dropdown {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
background: white;
|
||
border: 1px solid #ddd;
|
||
border-top: none;
|
||
border-radius: 0 0 4px 4px;
|
||
max-height: 280px;
|
||
overflow-y: auto;
|
||
display: none;
|
||
z-index: 200;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.search-dropdown.open {
|
||
display: block;
|
||
}
|
||
|
||
.search-dropdown-item {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 0.5rem 0.75rem;
|
||
border: none;
|
||
background: transparent;
|
||
text-align: left;
|
||
font-family: inherit;
|
||
font-size: 0.9rem;
|
||
cursor: pointer;
|
||
color: #333;
|
||
}
|
||
|
||
.search-dropdown-item:hover,
|
||
.search-dropdown-item.active {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.search-dropdown-item .person-name {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.search-dropdown-item .person-meta {
|
||
font-size: 0.8rem;
|
||
color: #888;
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.search-dropdown-item.kekule-person {
|
||
background: linear-gradient(to right, #fffde7, transparent);
|
||
}
|
||
|
||
.search-dropdown-item.kekule-person:hover,
|
||
.search-dropdown-item.kekule-person.active {
|
||
background: linear-gradient(to right, #fff9c4, #f5f5f5);
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="tree-page">
|
||
<div class="tree-header">
|
||
<h1>Interactive Family Tree</h1>
|
||
<p>Explore the biblical genealogy from Adam to Jesus Christ. Click on any person to see their details.</p>
|
||
</div>
|
||
|
||
<div class="tree-breadcrumb" id="tree-breadcrumb">
|
||
<button class="breadcrumb-btn back-btn" id="back-btn" disabled>← Back</button>
|
||
<span class="breadcrumb-sep">|</span>
|
||
<span>Viewing:</span>
|
||
<span class="breadcrumb-current" id="current-root-name">Adam</span>
|
||
</div>
|
||
|
||
<div class="tree-controls">
|
||
<div class="control-group search-group">
|
||
<label for="tree-search-input">Find:</label>
|
||
<div class="search-wrapper">
|
||
<input type="text" id="tree-search-input" placeholder="Search for a person..." autocomplete="off">
|
||
<div class="search-dropdown" id="tree-search-dropdown"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="control-divider"></div>
|
||
|
||
<div class="control-group">
|
||
<label for="root-select">Starting from:</label>
|
||
<select id="root-select">
|
||
<optgroup label="Patriarchs">
|
||
<option value="adam">Adam</option>
|
||
<option value="seth">Seth</option>
|
||
<option value="enoch">Enoch</option>
|
||
<option value="noah">Noah</option>
|
||
<option value="shem">Shem</option>
|
||
</optgroup>
|
||
<optgroup label="Abraham's Line">
|
||
<option value="abraham">Abraham</option>
|
||
<option value="isaac">Isaac</option>
|
||
<option value="jacob">Jacob (Israel)</option>
|
||
<option value="judah">Judah</option>
|
||
</optgroup>
|
||
<optgroup label="Royal Line">
|
||
<option value="david">David</option>
|
||
<option value="solomon">Solomon</option>
|
||
<option value="josiah">Josiah</option>
|
||
</optgroup>
|
||
<optgroup label="New Testament">
|
||
<option value="joseph">Joseph (husband of Mary)</option>
|
||
<option value="jesus">Jesus (Ancestors)</option>
|
||
</optgroup>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="control-divider"></div>
|
||
|
||
<div class="control-group">
|
||
<label>Generations:</label>
|
||
<select id="depth-select">
|
||
<option value="2">2</option>
|
||
<option value="3" selected>3</option>
|
||
<option value="4">4</option>
|
||
<option value="5">5</option>
|
||
<option value="6">6</option>
|
||
<option value="8">8</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="control-divider"></div>
|
||
|
||
<div class="control-group">
|
||
<button class="control-btn" id="zoom-in" title="Zoom In">+</button>
|
||
<button class="control-btn" id="zoom-out" title="Zoom Out">−</button>
|
||
<button class="control-btn" id="zoom-reset" title="Reset View">⟲</button>
|
||
<button class="control-btn" id="zoom-fit" title="Fit to View">⊡</button>
|
||
</div>
|
||
|
||
<div class="control-divider"></div>
|
||
|
||
<div class="control-group">
|
||
<label for="orientation-select">Layout:</label>
|
||
<select id="orientation-select">
|
||
<option value="left-right">Horizontal</option>
|
||
<option value="top-bottom" selected>Vertical</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tree-viewport">
|
||
<div id="family-tree-container">
|
||
<div class="tree-loading">Loading family tree...</div>
|
||
</div>
|
||
|
||
<div class="info-sidebar" id="info-sidebar">
|
||
<div class="info-sidebar-header">
|
||
<h3 id="sidebar-name">Person Name</h3>
|
||
<button class="info-sidebar-close" id="sidebar-close">×</button>
|
||
</div>
|
||
<div class="info-sidebar-content">
|
||
<div class="info-field" id="field-dates"></div>
|
||
<div class="info-field" id="field-age"></div>
|
||
<div class="info-field" id="field-generation"></div>
|
||
<div class="info-field" id="field-spouse"></div>
|
||
<div class="info-field" id="field-parents"></div>
|
||
<div class="info-field" id="field-children"></div>
|
||
<div class="info-actions">
|
||
<a href="#" id="view-profile-link">View Full Profile</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tree-hint">
|
||
Click to expand/collapse • Double-click to focus • Scroll to zoom • Drag to pan
|
||
</div>
|
||
</div>
|
||
|
||
<div class="back-link">
|
||
<a href="/family-tree">← Back to Family Tree</a>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||
<script>
|
||
// Family tree data from server
|
||
const familyTreeData = {{ family_tree_data | tojson }};
|
||
const generations = {{ generations | tojson }};
|
||
|
||
// State
|
||
let currentRoot = 'adam';
|
||
let currentDepth = 3;
|
||
let currentOrientation = 'top-bottom';
|
||
let transform = d3.zoomIdentity;
|
||
let currentDirection = 'descendants';
|
||
let treeRoot = null; // Store the D3 hierarchy root for updates
|
||
let navigationHistory = []; // Track navigation history for back button
|
||
let currentRootId = null; // Currently displayed root person ID
|
||
|
||
// DOM elements
|
||
const container = document.getElementById('family-tree-container');
|
||
const sidebar = document.getElementById('info-sidebar');
|
||
const backBtn = document.getElementById('back-btn');
|
||
const currentRootNameEl = document.getElementById('current-root-name');
|
||
|
||
// Update breadcrumb display
|
||
function updateBreadcrumb(personId) {
|
||
const person = familyTreeData[personId];
|
||
const name = person ? person.name : personId;
|
||
currentRootNameEl.textContent = name;
|
||
backBtn.disabled = navigationHistory.length === 0;
|
||
}
|
||
|
||
// Navigate back
|
||
function navigateBack() {
|
||
if (navigationHistory.length === 0) return;
|
||
|
||
const prevId = navigationHistory.pop();
|
||
transform = d3.zoomIdentity;
|
||
currentRootId = prevId;
|
||
renderTreeFromId(prevId);
|
||
updateBreadcrumb(prevId);
|
||
}
|
||
|
||
// Back button handler
|
||
backBtn.addEventListener('click', navigateBack);
|
||
|
||
// Find person by name
|
||
function findPersonId(name) {
|
||
const nameLower = name.toLowerCase();
|
||
for (const [id, person] of Object.entries(familyTreeData)) {
|
||
if (person.name.toLowerCase() === nameLower ||
|
||
person.name.toLowerCase().includes(nameLower)) {
|
||
return id;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Build tree data structure with collapse support
|
||
function buildTreeData(rootId, maxDepth, direction = 'descendants') {
|
||
const visited = new Set();
|
||
|
||
function buildNode(personId, depth) {
|
||
if (depth > maxDepth || visited.has(personId)) return null;
|
||
visited.add(personId);
|
||
|
||
const person = familyTreeData[personId];
|
||
if (!person) return null;
|
||
|
||
// Get potential children/parents
|
||
let relatedIds = [];
|
||
if (direction === 'descendants') {
|
||
relatedIds = person.children || [];
|
||
} else {
|
||
relatedIds = person.parents || [];
|
||
}
|
||
|
||
const node = {
|
||
id: personId,
|
||
data: {
|
||
name: person.name,
|
||
birth_year: person.birth_year,
|
||
death_year: person.death_year,
|
||
age_at_death: person.age_at_death,
|
||
generation: person.generation,
|
||
spouse: person.spouse,
|
||
gender: inferGender(person.name),
|
||
hasChildren: relatedIds.length > 0,
|
||
kekule_number: person.kekule_number
|
||
},
|
||
children: []
|
||
};
|
||
|
||
// Build children up to max depth
|
||
for (const relatedId of relatedIds) {
|
||
const child = buildNode(relatedId, depth + 1);
|
||
if (child) node.children.push(child);
|
||
}
|
||
|
||
return node;
|
||
}
|
||
|
||
return buildNode(rootId, 0);
|
||
}
|
||
|
||
// Toggle node expand/collapse
|
||
function toggleNode(d) {
|
||
if (d.children) {
|
||
// Collapse: store children in _children
|
||
d._children = d.children;
|
||
d.children = null;
|
||
} else if (d._children) {
|
||
// Expand: restore children from _children
|
||
d.children = d._children;
|
||
d._children = null;
|
||
} else if (d.data.data && d.data.data.hasChildren) {
|
||
// Node has potential children but none loaded - expand from source data
|
||
expandNodeFromSource(d);
|
||
}
|
||
updateTree(d);
|
||
}
|
||
|
||
// Expand a collapsed node by loading children from source data
|
||
function expandNodeFromSource(d) {
|
||
const personId = d.data.id;
|
||
const person = familyTreeData[personId];
|
||
if (!person) return;
|
||
|
||
const relatedIds = currentDirection === 'descendants'
|
||
? (person.children || [])
|
||
: (person.parents || []);
|
||
|
||
if (relatedIds.length === 0) return;
|
||
|
||
// Build child nodes
|
||
const childNodes = [];
|
||
for (const relatedId of relatedIds) {
|
||
const relatedPerson = familyTreeData[relatedId];
|
||
if (!relatedPerson) continue;
|
||
|
||
const relatedRelatedIds = currentDirection === 'descendants'
|
||
? (relatedPerson.children || [])
|
||
: (relatedPerson.parents || []);
|
||
|
||
childNodes.push({
|
||
id: relatedId,
|
||
data: {
|
||
name: relatedPerson.name,
|
||
birth_year: relatedPerson.birth_year,
|
||
death_year: relatedPerson.death_year,
|
||
age_at_death: relatedPerson.age_at_death,
|
||
generation: relatedPerson.generation,
|
||
spouse: relatedPerson.spouse,
|
||
gender: inferGender(relatedPerson.name),
|
||
hasChildren: relatedRelatedIds.length > 0,
|
||
kekule_number: relatedPerson.kekule_number
|
||
},
|
||
children: []
|
||
});
|
||
}
|
||
|
||
// Convert to D3 hierarchy nodes and attach
|
||
d.children = childNodes.map(childData => {
|
||
const childNode = d3.hierarchy(childData);
|
||
childNode.depth = d.depth + 1;
|
||
childNode.parent = d;
|
||
return childNode;
|
||
});
|
||
}
|
||
|
||
// Infer gender from name (simplified)
|
||
function inferGender(name) {
|
||
const femaleNames = ['eve', 'sarah', 'rebekah', 'rachel', 'leah', 'ruth', 'mary', 'tamar', 'rahab', 'bathsheba', 'dinah', 'keturah', 'hagar', 'zilpah', 'bilhah'];
|
||
return femaleNames.some(f => name.toLowerCase().includes(f)) ? 'female' : 'male';
|
||
}
|
||
|
||
// Create SVG card for person (with optional spouse)
|
||
function createPersonCard(d, showSpouse = true) {
|
||
const data = d.data.data || d.data;
|
||
const gender = data.gender || 'male';
|
||
const hasHiddenChildren = d._children && d._children.length > 0;
|
||
const hasExpandableChildren = data.hasChildren && !d.children && !d._children;
|
||
const isCollapsed = hasHiddenChildren || hasExpandableChildren;
|
||
const hasKekule = data.kekule_number !== null && data.kekule_number !== undefined;
|
||
|
||
let dates = '';
|
||
if (data.birth_year && data.birth_year !== 'Unknown') {
|
||
dates = data.birth_year;
|
||
if (data.death_year && data.death_year !== 'Unknown') {
|
||
dates += ' – ' + data.death_year;
|
||
}
|
||
}
|
||
|
||
const group = d3.create('svg:g')
|
||
.attr('class', 'person-node')
|
||
.attr('data-id', d.data.id)
|
||
.style('cursor', 'pointer');
|
||
|
||
// Card dimensions
|
||
const cardWidth = 150;
|
||
const cardHeight = dates ? 58 : 42;
|
||
|
||
// Determine colors based on gender and Kekulé status
|
||
let fillColor, strokeColor;
|
||
if (hasKekule) {
|
||
// Golden highlight for blood relations to Christ
|
||
fillColor = gender === 'female' ? '#fffde7' : '#fffde7';
|
||
strokeColor = '#d4af37';
|
||
} else {
|
||
fillColor = gender === 'female' ? '#fff8fb' : '#f8fbff';
|
||
strokeColor = gender === 'female' ? '#a55b80' : '#3b6ea5';
|
||
}
|
||
|
||
// Main person card background
|
||
group.append('rect')
|
||
.attr('class', 'card-bg')
|
||
.attr('x', -cardWidth / 2)
|
||
.attr('y', -cardHeight / 2)
|
||
.attr('width', cardWidth)
|
||
.attr('height', cardHeight)
|
||
.attr('rx', 6)
|
||
.attr('fill', fillColor)
|
||
.attr('stroke', strokeColor)
|
||
.attr('stroke-width', hasKekule ? 3 : 2);
|
||
|
||
// Add subtle glow for Kekulé ancestors
|
||
if (hasKekule) {
|
||
group.insert('rect', ':first-child')
|
||
.attr('class', 'card-glow')
|
||
.attr('x', -cardWidth / 2 - 3)
|
||
.attr('y', -cardHeight / 2 - 3)
|
||
.attr('width', cardWidth + 6)
|
||
.attr('height', cardHeight + 6)
|
||
.attr('rx', 8)
|
||
.attr('fill', 'none')
|
||
.attr('stroke', '#d4af37')
|
||
.attr('stroke-width', 2)
|
||
.attr('stroke-opacity', 0.3);
|
||
}
|
||
|
||
// Name text
|
||
group.append('text')
|
||
.attr('class', 'card-name')
|
||
.attr('y', dates ? -8 : 0)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'middle')
|
||
.attr('font-family', '"ETBembo", Palatino, "Book Antiqua", serif')
|
||
.attr('font-size', '13px')
|
||
.attr('font-weight', '600')
|
||
.attr('fill', '#222')
|
||
.text(data.name.length > 18 ? data.name.substring(0, 16) + '…' : data.name);
|
||
|
||
// Dates text
|
||
if (dates) {
|
||
group.append('text')
|
||
.attr('class', 'card-dates')
|
||
.attr('y', 10)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'middle')
|
||
.attr('font-family', '"ETBembo", Palatino, "Book Antiqua", serif')
|
||
.attr('font-size', '11px')
|
||
.attr('fill', '#666')
|
||
.text(dates);
|
||
}
|
||
|
||
// Generation badge
|
||
if (data.generation) {
|
||
group.append('circle')
|
||
.attr('cx', cardWidth / 2 - 6)
|
||
.attr('cy', -cardHeight / 2 + 6)
|
||
.attr('r', 10)
|
||
.attr('fill', '#4a7c59');
|
||
|
||
group.append('text')
|
||
.attr('x', cardWidth / 2 - 6)
|
||
.attr('y', -cardHeight / 2 + 6)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'middle')
|
||
.attr('font-size', '9px')
|
||
.attr('font-weight', 'bold')
|
||
.attr('fill', 'white')
|
||
.text(data.generation);
|
||
}
|
||
|
||
// Add spouse card if exists (positioned below main card)
|
||
if (showSpouse && data.spouse) {
|
||
const spouseId = findPersonId(data.spouse);
|
||
const spousePerson = spouseId ? familyTreeData[spouseId] : null;
|
||
const spouseGender = spousePerson ? inferGender(spousePerson.name) : (gender === 'male' ? 'female' : 'male');
|
||
|
||
const spouseY = cardHeight / 2 + 8;
|
||
const spouseCardHeight = 32;
|
||
|
||
// Marriage connector line
|
||
group.append('line')
|
||
.attr('class', 'spouse-connector')
|
||
.attr('x1', 0)
|
||
.attr('y1', cardHeight / 2)
|
||
.attr('x2', 0)
|
||
.attr('y2', spouseY)
|
||
.attr('stroke', '#d4af37')
|
||
.attr('stroke-width', 2);
|
||
|
||
// Spouse card (smaller)
|
||
const spouseGroup = group.append('g')
|
||
.attr('class', 'spouse-card')
|
||
.attr('transform', `translate(0, ${spouseY + spouseCardHeight / 2})`)
|
||
.style('cursor', 'pointer')
|
||
.on('click', (event) => {
|
||
event.stopPropagation();
|
||
if (spouseId) {
|
||
// Show spouse info
|
||
showPersonInfo({ id: spouseId, data: spousePerson });
|
||
}
|
||
})
|
||
.on('dblclick', (event) => {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
if (spouseId) {
|
||
// Push current root to history before navigating
|
||
if (currentRootId) {
|
||
navigationHistory.push(currentRootId);
|
||
}
|
||
currentRootId = spouseId;
|
||
transform = d3.zoomIdentity;
|
||
renderTreeFromId(spouseId);
|
||
updateBreadcrumb(spouseId);
|
||
}
|
||
});
|
||
|
||
spouseGroup.append('rect')
|
||
.attr('class', 'spouse-bg')
|
||
.attr('x', -cardWidth / 2 + 10)
|
||
.attr('y', -spouseCardHeight / 2)
|
||
.attr('width', cardWidth - 20)
|
||
.attr('height', spouseCardHeight)
|
||
.attr('rx', 4)
|
||
.attr('fill', spouseGender === 'female' ? '#fff0f5' : '#f0f5ff')
|
||
.attr('stroke', spouseGender === 'female' ? '#d4a0b9' : '#a0b9d4')
|
||
.attr('stroke-width', 1.5)
|
||
.attr('stroke-dasharray', '4,2');
|
||
|
||
spouseGroup.append('text')
|
||
.attr('y', 0)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'middle')
|
||
.attr('font-family', '"ETBembo", Palatino, "Book Antiqua", serif')
|
||
.attr('font-size', '11px')
|
||
.attr('fill', '#555')
|
||
.text(data.spouse.length > 16 ? data.spouse.substring(0, 14) + '…' : data.spouse);
|
||
|
||
// Ring icon to indicate marriage
|
||
spouseGroup.append('text')
|
||
.attr('x', -cardWidth / 2 + 20)
|
||
.attr('y', 0)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'middle')
|
||
.attr('font-size', '10px')
|
||
.text('💍');
|
||
}
|
||
|
||
// Expand/collapse indicator (shows + if collapsed with children, - if expanded)
|
||
if (isCollapsed || (d.children && d.children.length > 0)) {
|
||
const indicatorX = cardWidth / 2 + 8;
|
||
const indicatorY = 0;
|
||
|
||
group.append('circle')
|
||
.attr('class', 'expand-indicator')
|
||
.attr('cx', indicatorX)
|
||
.attr('cy', indicatorY)
|
||
.attr('r', 10)
|
||
.attr('fill', isCollapsed ? '#4a7c59' : '#888')
|
||
.attr('stroke', '#fff')
|
||
.attr('stroke-width', 1);
|
||
|
||
group.append('text')
|
||
.attr('class', 'expand-icon')
|
||
.attr('x', indicatorX)
|
||
.attr('y', indicatorY)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'middle')
|
||
.attr('font-size', '14px')
|
||
.attr('font-weight', 'bold')
|
||
.attr('fill', 'white')
|
||
.text(isCollapsed ? '+' : '−');
|
||
}
|
||
|
||
return group.node();
|
||
}
|
||
|
||
// Render the tree
|
||
function renderTree() {
|
||
container.innerHTML = '';
|
||
|
||
// Get root person
|
||
let rootId;
|
||
let direction = 'descendants';
|
||
|
||
// Map root selection to person search queries
|
||
const rootMappings = {
|
||
'adam': 'Adam',
|
||
'seth': 'Seth',
|
||
'enoch': 'Enoch',
|
||
'noah': 'Noah',
|
||
'shem': 'Shem',
|
||
'abraham': 'Abraham',
|
||
'isaac': 'Isaac',
|
||
'jacob': 'Jacob',
|
||
'judah': 'Judah',
|
||
'david': 'David',
|
||
'solomon': 'Solomon',
|
||
'josiah': 'Josiah',
|
||
'joseph': 'Joseph',
|
||
'jesus': 'Jesus'
|
||
};
|
||
|
||
const searchName = rootMappings[currentRoot] || 'Adam';
|
||
|
||
// Special case for Jesus - show ancestors instead of descendants
|
||
if (currentRoot === 'jesus') {
|
||
rootId = findPersonId('Jesus') || findPersonId('Jesus Christ');
|
||
direction = 'ancestors';
|
||
} else if (currentRoot === 'joseph') {
|
||
// Find Joseph who is father of Jesus (not other Josephs)
|
||
for (const [id, person] of Object.entries(familyTreeData)) {
|
||
if (person.name === 'Joseph' && person.children && person.children.length > 0) {
|
||
const hasJesusChild = person.children.some(childId => {
|
||
const child = familyTreeData[childId];
|
||
return child && child.name === 'Jesus';
|
||
});
|
||
if (hasJesusChild) {
|
||
rootId = id;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!rootId) rootId = findPersonId('Joseph');
|
||
} else {
|
||
rootId = findPersonId(searchName);
|
||
}
|
||
|
||
if (!rootId) {
|
||
container.innerHTML = '<div class="tree-loading">Person not found in family tree</div>';
|
||
return;
|
||
}
|
||
|
||
// Initialize current root and clear history when starting fresh
|
||
currentRootId = rootId;
|
||
navigationHistory = [];
|
||
updateBreadcrumb(rootId);
|
||
|
||
const treeData = buildTreeData(rootId, currentDepth, direction);
|
||
if (!treeData) {
|
||
container.innerHTML = '<div class="tree-loading">Could not build tree</div>';
|
||
return;
|
||
}
|
||
|
||
// Create SVG
|
||
const width = container.clientWidth;
|
||
const height = container.clientHeight;
|
||
|
||
const svg = d3.select(container)
|
||
.append('svg')
|
||
.attr('width', width)
|
||
.attr('height', height);
|
||
|
||
const g = svg.append('g');
|
||
|
||
// Zoom behavior
|
||
const zoom = d3.zoom()
|
||
.scaleExtent([0.1, 4])
|
||
.on('zoom', (event) => {
|
||
transform = event.transform;
|
||
g.attr('transform', event.transform);
|
||
});
|
||
|
||
svg.call(zoom);
|
||
svg.call(zoom.transform, transform);
|
||
|
||
// Create hierarchy
|
||
const root = d3.hierarchy(treeData);
|
||
|
||
// Calculate tree dimensions
|
||
const nodeWidth = 180;
|
||
const nodeHeight = 80;
|
||
const isHorizontal = currentOrientation === 'left-right';
|
||
|
||
// Tree layout
|
||
const treeLayout = d3.tree()
|
||
.nodeSize(isHorizontal ? [nodeHeight, nodeWidth] : [nodeWidth, nodeHeight]);
|
||
|
||
treeLayout(root);
|
||
|
||
// Center the tree
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
root.descendants().forEach(d => {
|
||
const x = isHorizontal ? d.y : d.x;
|
||
const y = isHorizontal ? d.x : d.y;
|
||
minX = Math.min(minX, x);
|
||
maxX = Math.max(maxX, x);
|
||
minY = Math.min(minY, y);
|
||
maxY = Math.max(maxY, y);
|
||
});
|
||
|
||
const offsetX = width / 2 - (minX + maxX) / 2;
|
||
const offsetY = height / 2 - (minY + maxY) / 2;
|
||
g.attr('transform', `translate(${offsetX}, ${offsetY})`);
|
||
|
||
// Draw links
|
||
const linkGenerator = isHorizontal
|
||
? d3.linkHorizontal().x(d => d.y).y(d => d.x)
|
||
: d3.linkVertical().x(d => d.x).y(d => d.y);
|
||
|
||
g.selectAll('.link')
|
||
.data(root.links())
|
||
.join('path')
|
||
.attr('class', 'link')
|
||
.attr('fill', 'none')
|
||
.attr('stroke', '#b8b8b8')
|
||
.attr('stroke-width', 2)
|
||
.attr('d', linkGenerator);
|
||
|
||
// Draw nodes
|
||
const nodes = g.selectAll('.node')
|
||
.data(root.descendants())
|
||
.join('g')
|
||
.attr('class', 'node')
|
||
.attr('transform', d => {
|
||
const x = isHorizontal ? d.y : d.x;
|
||
const y = isHorizontal ? d.x : d.y;
|
||
return `translate(${x}, ${y})`;
|
||
});
|
||
|
||
// Add cards to nodes
|
||
nodes.each(function(d) {
|
||
const card = createPersonCard(d);
|
||
this.appendChild(card);
|
||
});
|
||
|
||
// Single click: expand/collapse children OR show info if leaf node
|
||
nodes.on('click', (event, d) => {
|
||
event.stopPropagation();
|
||
|
||
// Check if clicking on the expand indicator
|
||
const target = event.target;
|
||
const isExpandClick = target.classList.contains('expand-indicator') ||
|
||
target.classList.contains('expand-icon') ||
|
||
target.closest('.expand-indicator');
|
||
|
||
// If has children (visible or hidden) or can be expanded, toggle on click
|
||
const canToggle = d.children || d._children ||
|
||
(d.data.data && d.data.data.hasChildren);
|
||
|
||
if (canToggle) {
|
||
toggleNode(d);
|
||
} else {
|
||
// Leaf node - show info
|
||
showPersonInfo(d.data);
|
||
}
|
||
});
|
||
|
||
// Double click: re-center tree on this person
|
||
nodes.on('dblclick', (event, d) => {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
|
||
// Set this person as the new root and re-render
|
||
const personId = d.data.id;
|
||
|
||
// Push current root to history before navigating
|
||
if (currentRootId && currentRootId !== personId) {
|
||
navigationHistory.push(currentRootId);
|
||
}
|
||
currentRootId = personId;
|
||
|
||
// Update dropdown to show custom selection
|
||
const rootSelect = document.getElementById('root-select');
|
||
// Check if this person is one of the preset options
|
||
const presetMatch = Array.from(rootSelect.options).find(opt => {
|
||
const presetId = findPersonId(opt.value === 'jesus' ? 'Jesus' :
|
||
opt.value.charAt(0).toUpperCase() + opt.value.slice(1));
|
||
return presetId === personId;
|
||
});
|
||
|
||
if (!presetMatch) {
|
||
// Add custom option if not already present
|
||
let customOpt = rootSelect.querySelector('option[value="custom"]');
|
||
if (!customOpt) {
|
||
customOpt = document.createElement('option');
|
||
customOpt.value = 'custom';
|
||
rootSelect.appendChild(customOpt);
|
||
}
|
||
const personName = familyTreeData[personId]?.name || personId;
|
||
customOpt.textContent = personName;
|
||
rootSelect.value = 'custom';
|
||
}
|
||
|
||
// Reset zoom transform for fresh view
|
||
transform = d3.zoomIdentity;
|
||
|
||
// Re-render with new root
|
||
renderTreeFromId(personId);
|
||
updateBreadcrumb(personId);
|
||
});
|
||
|
||
// Hover effect
|
||
nodes.on('mouseenter', function() {
|
||
d3.select(this).select('.card-bg')
|
||
.transition().duration(150)
|
||
.attr('stroke-width', 3);
|
||
}).on('mouseleave', function() {
|
||
d3.select(this).select('.card-bg')
|
||
.transition().duration(150)
|
||
.attr('stroke-width', 2);
|
||
});
|
||
|
||
// Store zoom for controls
|
||
window.treeZoom = zoom;
|
||
window.treeSvg = svg;
|
||
window.treeG = g;
|
||
|
||
// Initial fit to view
|
||
fitToView();
|
||
}
|
||
|
||
// Re-render tree from a specific person ID
|
||
function renderTreeFromId(personId) {
|
||
container.innerHTML = '';
|
||
|
||
const treeData = buildTreeData(personId, currentDepth, currentDirection);
|
||
if (!treeData) {
|
||
container.innerHTML = '<div class="tree-loading">Could not build tree</div>';
|
||
return;
|
||
}
|
||
|
||
// Create SVG
|
||
const width = container.clientWidth;
|
||
const height = container.clientHeight;
|
||
|
||
const svg = d3.select(container)
|
||
.append('svg')
|
||
.attr('width', width)
|
||
.attr('height', height);
|
||
|
||
const g = svg.append('g');
|
||
|
||
// Zoom behavior
|
||
const zoom = d3.zoom()
|
||
.scaleExtent([0.1, 4])
|
||
.on('zoom', (event) => {
|
||
transform = event.transform;
|
||
g.attr('transform', event.transform);
|
||
});
|
||
|
||
svg.call(zoom);
|
||
|
||
// Create hierarchy
|
||
treeRoot = d3.hierarchy(treeData);
|
||
|
||
// Calculate tree dimensions
|
||
const nodeWidth = 180;
|
||
const nodeHeight = 80;
|
||
const isHorizontal = currentOrientation === 'left-right';
|
||
|
||
// Tree layout
|
||
const treeLayout = d3.tree()
|
||
.nodeSize(isHorizontal ? [nodeHeight, nodeWidth] : [nodeWidth, nodeHeight]);
|
||
|
||
treeLayout(treeRoot);
|
||
|
||
// Center the tree
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
treeRoot.descendants().forEach(d => {
|
||
const x = isHorizontal ? d.y : d.x;
|
||
const y = isHorizontal ? d.x : d.y;
|
||
minX = Math.min(minX, x);
|
||
maxX = Math.max(maxX, x);
|
||
minY = Math.min(minY, y);
|
||
maxY = Math.max(maxY, y);
|
||
});
|
||
|
||
const offsetX = width / 2 - (minX + maxX) / 2;
|
||
const offsetY = height / 2 - (minY + maxY) / 2;
|
||
g.attr('transform', `translate(${offsetX}, ${offsetY})`);
|
||
|
||
// Draw links
|
||
const linkGenerator = isHorizontal
|
||
? d3.linkHorizontal().x(d => d.y).y(d => d.x)
|
||
: d3.linkVertical().x(d => d.x).y(d => d.y);
|
||
|
||
g.selectAll('.link')
|
||
.data(treeRoot.links())
|
||
.join('path')
|
||
.attr('class', 'link')
|
||
.attr('fill', 'none')
|
||
.attr('stroke', '#b8b8b8')
|
||
.attr('stroke-width', 2)
|
||
.attr('d', linkGenerator);
|
||
|
||
// Draw nodes
|
||
const nodes = g.selectAll('.node')
|
||
.data(treeRoot.descendants())
|
||
.join('g')
|
||
.attr('class', 'node')
|
||
.attr('transform', d => {
|
||
const x = isHorizontal ? d.y : d.x;
|
||
const y = isHorizontal ? d.x : d.y;
|
||
return `translate(${x}, ${y})`;
|
||
});
|
||
|
||
// Add cards to nodes
|
||
nodes.each(function(d) {
|
||
const card = createPersonCard(d);
|
||
this.appendChild(card);
|
||
});
|
||
|
||
// Single click: expand/collapse
|
||
nodes.on('click', (event, d) => {
|
||
event.stopPropagation();
|
||
const canToggle = d.children || d._children ||
|
||
(d.data.data && d.data.data.hasChildren);
|
||
if (canToggle) {
|
||
toggleNode(d);
|
||
} else {
|
||
showPersonInfo(d.data);
|
||
}
|
||
});
|
||
|
||
// Double click: re-center
|
||
nodes.on('dblclick', (event, d) => {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
const personId = d.data.id;
|
||
|
||
// Push current root to history before navigating
|
||
if (currentRootId && currentRootId !== personId) {
|
||
navigationHistory.push(currentRootId);
|
||
}
|
||
currentRootId = personId;
|
||
|
||
transform = d3.zoomIdentity;
|
||
renderTreeFromId(personId);
|
||
updateBreadcrumb(personId);
|
||
});
|
||
|
||
// Hover effect
|
||
nodes.on('mouseenter', function() {
|
||
d3.select(this).select('.card-bg')
|
||
.transition().duration(150)
|
||
.attr('stroke-width', 3);
|
||
}).on('mouseleave', function() {
|
||
d3.select(this).select('.card-bg')
|
||
.transition().duration(150)
|
||
.attr('stroke-width', 2);
|
||
});
|
||
|
||
// Store for controls
|
||
window.treeZoom = zoom;
|
||
window.treeSvg = svg;
|
||
window.treeG = g;
|
||
|
||
fitToView();
|
||
}
|
||
|
||
// Update tree after expand/collapse with animation
|
||
function updateTree(source) {
|
||
if (!treeRoot || !window.treeG) return;
|
||
|
||
const g = window.treeG;
|
||
const isHorizontal = currentOrientation === 'left-right';
|
||
const nodeWidth = 180;
|
||
const nodeHeight = 80;
|
||
|
||
// Re-compute tree layout
|
||
const treeLayout = d3.tree()
|
||
.nodeSize(isHorizontal ? [nodeHeight, nodeWidth] : [nodeWidth, nodeHeight]);
|
||
|
||
treeLayout(treeRoot);
|
||
|
||
const duration = 400;
|
||
|
||
// Update links
|
||
const linkGenerator = isHorizontal
|
||
? d3.linkHorizontal().x(d => d.y).y(d => d.x)
|
||
: d3.linkVertical().x(d => d.x).y(d => d.y);
|
||
|
||
const links = g.selectAll('.link')
|
||
.data(treeRoot.links(), d => d.target.data.id);
|
||
|
||
// Enter new links
|
||
links.enter()
|
||
.append('path')
|
||
.attr('class', 'link')
|
||
.attr('fill', 'none')
|
||
.attr('stroke', '#b8b8b8')
|
||
.attr('stroke-width', 2)
|
||
.attr('d', d => {
|
||
const o = { x: source.x0 || source.x, y: source.y0 || source.y };
|
||
return linkGenerator({ source: o, target: o });
|
||
})
|
||
.transition()
|
||
.duration(duration)
|
||
.attr('d', linkGenerator);
|
||
|
||
// Update existing links
|
||
links.transition()
|
||
.duration(duration)
|
||
.attr('d', linkGenerator);
|
||
|
||
// Remove old links
|
||
links.exit()
|
||
.transition()
|
||
.duration(duration)
|
||
.attr('d', d => {
|
||
const o = { x: source.x, y: source.y };
|
||
return linkGenerator({ source: o, target: o });
|
||
})
|
||
.remove();
|
||
|
||
// Update nodes
|
||
const nodes = g.selectAll('.node')
|
||
.data(treeRoot.descendants(), d => d.data.id);
|
||
|
||
// Enter new nodes
|
||
const nodesEnter = nodes.enter()
|
||
.append('g')
|
||
.attr('class', 'node')
|
||
.attr('transform', d => {
|
||
const x = isHorizontal ? (source.y0 || source.y) : (source.x0 || source.x);
|
||
const y = isHorizontal ? (source.x0 || source.x) : (source.y0 || source.y);
|
||
return `translate(${x}, ${y})`;
|
||
})
|
||
.style('opacity', 0);
|
||
|
||
nodesEnter.each(function(d) {
|
||
const card = createPersonCard(d);
|
||
this.appendChild(card);
|
||
});
|
||
|
||
// Add event handlers to new nodes
|
||
nodesEnter
|
||
.on('click', (event, d) => {
|
||
event.stopPropagation();
|
||
const canToggle = d.children || d._children ||
|
||
(d.data.data && d.data.data.hasChildren);
|
||
if (canToggle) {
|
||
toggleNode(d);
|
||
} else {
|
||
showPersonInfo(d.data);
|
||
}
|
||
})
|
||
.on('dblclick', (event, d) => {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
const personId = d.data.id;
|
||
|
||
// Push current root to history before navigating
|
||
if (currentRootId && currentRootId !== personId) {
|
||
navigationHistory.push(currentRootId);
|
||
}
|
||
currentRootId = personId;
|
||
|
||
transform = d3.zoomIdentity;
|
||
renderTreeFromId(personId);
|
||
updateBreadcrumb(personId);
|
||
})
|
||
.on('mouseenter', function() {
|
||
d3.select(this).select('.card-bg')
|
||
.transition().duration(150)
|
||
.attr('stroke-width', 3);
|
||
})
|
||
.on('mouseleave', function() {
|
||
d3.select(this).select('.card-bg')
|
||
.transition().duration(150)
|
||
.attr('stroke-width', 2);
|
||
});
|
||
|
||
nodesEnter.transition()
|
||
.duration(duration)
|
||
.attr('transform', d => {
|
||
const x = isHorizontal ? d.y : d.x;
|
||
const y = isHorizontal ? d.x : d.y;
|
||
return `translate(${x}, ${y})`;
|
||
})
|
||
.style('opacity', 1);
|
||
|
||
// Update existing nodes - need to recreate cards for correct +/- icons
|
||
nodes.each(function(d) {
|
||
// Clear existing content and recreate card
|
||
d3.select(this).selectAll('*').remove();
|
||
const card = createPersonCard(d);
|
||
this.appendChild(card);
|
||
});
|
||
|
||
nodes.transition()
|
||
.duration(duration)
|
||
.attr('transform', d => {
|
||
const x = isHorizontal ? d.y : d.x;
|
||
const y = isHorizontal ? d.x : d.y;
|
||
return `translate(${x}, ${y})`;
|
||
});
|
||
|
||
// Remove old nodes
|
||
nodes.exit()
|
||
.transition()
|
||
.duration(duration)
|
||
.attr('transform', d => {
|
||
const x = isHorizontal ? source.y : source.x;
|
||
const y = isHorizontal ? source.x : source.y;
|
||
return `translate(${x}, ${y})`;
|
||
})
|
||
.style('opacity', 0)
|
||
.remove();
|
||
|
||
// Store positions for next transition
|
||
treeRoot.descendants().forEach(d => {
|
||
d.x0 = d.x;
|
||
d.y0 = d.y;
|
||
});
|
||
}
|
||
|
||
// Fit tree to view
|
||
function fitToView() {
|
||
const svg = window.treeSvg;
|
||
const zoom = window.treeZoom;
|
||
if (!svg || !zoom) return;
|
||
|
||
const bounds = svg.select('g').node().getBBox();
|
||
const width = container.clientWidth;
|
||
const height = container.clientHeight;
|
||
const padding = 60;
|
||
|
||
// Calculate scale to fit, but ensure minimum zoom for readability
|
||
let scale = Math.min(
|
||
(width - padding * 2) / bounds.width,
|
||
(height - padding * 2) / bounds.height,
|
||
2.0 // Max zoom
|
||
);
|
||
|
||
// Ensure minimum zoom of 0.8 for readability
|
||
scale = Math.max(scale, 0.8);
|
||
|
||
const tx = width / 2 - (bounds.x + bounds.width / 2) * scale;
|
||
const ty = height / 2 - (bounds.y + bounds.height / 2) * scale;
|
||
|
||
svg.transition()
|
||
.duration(500)
|
||
.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
|
||
}
|
||
|
||
// Show person info in sidebar
|
||
function showPersonInfo(person) {
|
||
const data = person.data;
|
||
const fullPerson = familyTreeData[person.id];
|
||
|
||
document.getElementById('sidebar-name').textContent = data.name;
|
||
document.getElementById('view-profile-link').href = `/family-tree/person/${person.id}`;
|
||
|
||
// Dates
|
||
const datesField = document.getElementById('field-dates');
|
||
if (data.birth_year !== 'Unknown' || data.death_year !== 'Unknown') {
|
||
let dates = '';
|
||
if (data.birth_year !== 'Unknown') dates += `Born: ${data.birth_year}`;
|
||
if (data.death_year !== 'Unknown') {
|
||
if (dates) dates += '<br>';
|
||
dates += `Died: ${data.death_year}`;
|
||
}
|
||
datesField.innerHTML = `<div class="info-field-label">Dates</div><div class="info-field-value">${dates}</div>`;
|
||
datesField.style.display = 'block';
|
||
} else {
|
||
datesField.style.display = 'none';
|
||
}
|
||
|
||
// Age
|
||
const ageField = document.getElementById('field-age');
|
||
if (data.age_at_death && data.age_at_death !== 'Unknown') {
|
||
ageField.innerHTML = `<div class="info-field-label">Lifespan</div><div class="info-field-value">${data.age_at_death}</div>`;
|
||
ageField.style.display = 'block';
|
||
} else {
|
||
ageField.style.display = 'none';
|
||
}
|
||
|
||
// Generation
|
||
const genField = document.getElementById('field-generation');
|
||
if (data.generation) {
|
||
genField.innerHTML = `<div class="info-field-label">Generation</div><div class="info-field-value">${data.generation} from Adam</div>`;
|
||
genField.style.display = 'block';
|
||
} else {
|
||
genField.style.display = 'none';
|
||
}
|
||
|
||
// Spouse
|
||
const spouseField = document.getElementById('field-spouse');
|
||
if (data.spouse) {
|
||
const spouseId = findPersonId(data.spouse);
|
||
const spouseLink = spouseId ? `<a href="/family-tree/person/${spouseId}">${data.spouse}</a>` : data.spouse;
|
||
spouseField.innerHTML = `<div class="info-field-label">Spouse</div><div class="info-field-value">${spouseLink}</div>`;
|
||
spouseField.style.display = 'block';
|
||
} else {
|
||
spouseField.style.display = 'none';
|
||
}
|
||
|
||
// Parents
|
||
const parentsField = document.getElementById('field-parents');
|
||
if (fullPerson && fullPerson.parents && fullPerson.parents.length > 0) {
|
||
const parentLinks = fullPerson.parents.map(pid => {
|
||
const parent = familyTreeData[pid];
|
||
return parent ? `<a href="/family-tree/person/${pid}">${parent.name}</a>` : pid;
|
||
}).join(', ');
|
||
parentsField.innerHTML = `<div class="info-field-label">Parents</div><div class="info-field-value">${parentLinks}</div>`;
|
||
parentsField.style.display = 'block';
|
||
} else {
|
||
parentsField.style.display = 'none';
|
||
}
|
||
|
||
// Children
|
||
const childrenField = document.getElementById('field-children');
|
||
if (fullPerson && fullPerson.children && fullPerson.children.length > 0) {
|
||
const childCount = fullPerson.children.length;
|
||
childrenField.innerHTML = `<div class="info-field-label">Children</div><div class="info-field-value">${childCount} child${childCount !== 1 ? 'ren' : ''}</div>`;
|
||
childrenField.style.display = 'block';
|
||
} else {
|
||
childrenField.style.display = 'none';
|
||
}
|
||
|
||
sidebar.classList.add('open');
|
||
}
|
||
|
||
// Close sidebar
|
||
document.getElementById('sidebar-close').addEventListener('click', () => {
|
||
sidebar.classList.remove('open');
|
||
});
|
||
|
||
// Controls
|
||
document.getElementById('root-select').addEventListener('change', (e) => {
|
||
currentRoot = e.target.value;
|
||
renderTree();
|
||
});
|
||
|
||
document.getElementById('depth-select').addEventListener('change', (e) => {
|
||
currentDepth = parseInt(e.target.value);
|
||
renderTree();
|
||
});
|
||
|
||
document.getElementById('orientation-select').addEventListener('change', (e) => {
|
||
currentOrientation = e.target.value;
|
||
renderTree();
|
||
});
|
||
|
||
document.getElementById('zoom-in').addEventListener('click', () => {
|
||
if (window.treeSvg && window.treeZoom) {
|
||
window.treeSvg.transition().duration(200).call(window.treeZoom.scaleBy, 1.4);
|
||
}
|
||
});
|
||
|
||
document.getElementById('zoom-out').addEventListener('click', () => {
|
||
if (window.treeSvg && window.treeZoom) {
|
||
window.treeSvg.transition().duration(200).call(window.treeZoom.scaleBy, 0.7);
|
||
}
|
||
});
|
||
|
||
document.getElementById('zoom-reset').addEventListener('click', () => {
|
||
if (window.treeSvg && window.treeZoom) {
|
||
window.treeSvg.transition().duration(300).call(window.treeZoom.transform, d3.zoomIdentity);
|
||
}
|
||
});
|
||
|
||
document.getElementById('zoom-fit').addEventListener('click', fitToView);
|
||
|
||
// Handle window resize
|
||
let resizeTimeout;
|
||
window.addEventListener('resize', () => {
|
||
clearTimeout(resizeTimeout);
|
||
resizeTimeout = setTimeout(renderTree, 200);
|
||
});
|
||
|
||
// Search functionality
|
||
const searchInput = document.getElementById('tree-search-input');
|
||
const searchDropdown = document.getElementById('tree-search-dropdown');
|
||
|
||
// Build searchable names list from family tree data
|
||
const searchablePersons = Object.entries(familyTreeData).map(([id, person]) => ({
|
||
id,
|
||
name: person.name,
|
||
generation: person.generation,
|
||
kekule_number: person.kekule_number
|
||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||
|
||
let searchFiltered = [];
|
||
let searchActiveIndex = -1;
|
||
|
||
function hideSearchDropdown() {
|
||
searchDropdown.classList.remove('open');
|
||
searchDropdown.innerHTML = '';
|
||
searchFiltered = [];
|
||
searchActiveIndex = -1;
|
||
}
|
||
|
||
function renderSearchDropdown(items) {
|
||
if (!items.length) {
|
||
hideSearchDropdown();
|
||
return;
|
||
}
|
||
|
||
searchDropdown.innerHTML = items.map((person, idx) => {
|
||
const hasKekule = person.kekule_number !== null && person.kekule_number !== undefined;
|
||
const kekuleClass = hasKekule ? ' kekule-person' : '';
|
||
const meta = [];
|
||
if (person.generation) meta.push(`Gen ${person.generation}`);
|
||
if (hasKekule) meta.push(`Kekulé #${person.kekule_number}`);
|
||
|
||
return `<button type="button" class="search-dropdown-item${kekuleClass}" data-index="${idx}">
|
||
<span class="person-name">${person.name}</span>
|
||
${meta.length ? `<span class="person-meta">(${meta.join(', ')})</span>` : ''}
|
||
</button>`;
|
||
}).join('');
|
||
|
||
searchDropdown.classList.add('open');
|
||
searchActiveIndex = -1;
|
||
}
|
||
|
||
function setSearchActive(index) {
|
||
const buttons = searchDropdown.querySelectorAll('.search-dropdown-item');
|
||
buttons.forEach(btn => btn.classList.remove('active'));
|
||
if (buttons[index]) {
|
||
buttons[index].classList.add('active');
|
||
buttons[index].scrollIntoView({ block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
function selectPerson(personId) {
|
||
hideSearchDropdown();
|
||
searchInput.value = '';
|
||
|
||
// Push current root to history
|
||
if (currentRootId && currentRootId !== personId) {
|
||
navigationHistory.push(currentRootId);
|
||
}
|
||
currentRootId = personId;
|
||
|
||
// Update dropdown selector
|
||
const rootSelect = document.getElementById('root-select');
|
||
let customOpt = rootSelect.querySelector('option[value="custom"]');
|
||
if (!customOpt) {
|
||
customOpt = document.createElement('option');
|
||
customOpt.value = 'custom';
|
||
rootSelect.appendChild(customOpt);
|
||
}
|
||
const personName = familyTreeData[personId]?.name || personId;
|
||
customOpt.textContent = personName;
|
||
rootSelect.value = 'custom';
|
||
|
||
// Reset zoom and render
|
||
transform = d3.zoomIdentity;
|
||
renderTreeFromId(personId);
|
||
updateBreadcrumb(personId);
|
||
}
|
||
|
||
searchInput.addEventListener('input', function() {
|
||
const query = searchInput.value.trim().toLowerCase();
|
||
if (!query) {
|
||
hideSearchDropdown();
|
||
return;
|
||
}
|
||
|
||
searchFiltered = searchablePersons
|
||
.filter(p => p.name.toLowerCase().includes(query))
|
||
.slice(0, 12);
|
||
|
||
renderSearchDropdown(searchFiltered);
|
||
});
|
||
|
||
searchInput.addEventListener('keydown', function(event) {
|
||
if (!searchDropdown.classList.contains('open')) {
|
||
return;
|
||
}
|
||
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
searchActiveIndex = (searchActiveIndex + 1) % searchFiltered.length;
|
||
setSearchActive(searchActiveIndex);
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
searchActiveIndex = (searchActiveIndex - 1 + searchFiltered.length) % searchFiltered.length;
|
||
setSearchActive(searchActiveIndex);
|
||
} else if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
if (searchActiveIndex >= 0 && searchFiltered[searchActiveIndex]) {
|
||
selectPerson(searchFiltered[searchActiveIndex].id);
|
||
} else if (searchFiltered.length > 0) {
|
||
selectPerson(searchFiltered[0].id);
|
||
}
|
||
} else if (event.key === 'Escape') {
|
||
hideSearchDropdown();
|
||
searchInput.blur();
|
||
}
|
||
});
|
||
|
||
searchDropdown.addEventListener('mousedown', function(event) {
|
||
const button = event.target.closest('.search-dropdown-item');
|
||
if (!button) return;
|
||
|
||
const index = Number(button.dataset.index);
|
||
if (!Number.isNaN(index) && searchFiltered[index]) {
|
||
selectPerson(searchFiltered[index].id);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', function(event) {
|
||
if (!searchDropdown.contains(event.target) && event.target !== searchInput) {
|
||
hideSearchDropdown();
|
||
}
|
||
});
|
||
|
||
// Initial render
|
||
renderTree();
|
||
</script>
|
||
{% endblock %}
|