mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
3336863a4d
- Add KJVNav.initGridNav for standardized 2D grid navigation - Migrate books.html, topics.html, resources.html to use initGridNav - Add sidebarActive check to all templates with custom keyboard handlers - Add [ and ] shortcuts for prev/next chapter on chapter pages - Add [ and ] shortcuts for prev/next book on book pages - Update accessibility page with comprehensive keyboard shortcut docs - Add honest note about keyboard navigation complexity - Fix sidebar nav conflicting with main content selection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
871 lines
24 KiB
HTML
871 lines
24 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>
|
||
.tree-header {
|
||
max-width: 55%;
|
||
margin: 2rem 0;
|
||
padding-bottom: 1rem;
|
||
border-bottom: 2px solid #111;
|
||
}
|
||
|
||
.tree-title {
|
||
font-size: 2.5rem;
|
||
font-weight: 400;
|
||
margin: 0 0 0.5rem 0;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.tree-subtitle {
|
||
font-size: 1.2rem;
|
||
color: #666;
|
||
font-style: italic;
|
||
}
|
||
|
||
.intro-text {
|
||
max-width: 55%;
|
||
font-size: 1.1rem;
|
||
line-height: 1.8;
|
||
margin: 1.5rem 0;
|
||
}
|
||
|
||
/* Controls */
|
||
.tree-controls {
|
||
max-width: 55%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1.5rem;
|
||
margin: 1.5rem 0;
|
||
padding: 1rem 0;
|
||
border-bottom: 1px solid #ccc;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.control-group label {
|
||
font-size: 0.95rem;
|
||
color: #555;
|
||
}
|
||
|
||
.control-group select {
|
||
font-family: inherit;
|
||
font-size: 0.95rem;
|
||
padding: 0.4rem 0.6rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
background: white;
|
||
}
|
||
|
||
.control-group input[type="text"] {
|
||
font-family: inherit;
|
||
font-size: 0.95rem;
|
||
padding: 0.4rem 0.6rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
width: 180px;
|
||
}
|
||
|
||
.btn {
|
||
font-family: inherit;
|
||
font-size: 0.9rem;
|
||
padding: 0.4rem 0.8rem;
|
||
border: 1px solid #4a7c59;
|
||
border-radius: 3px;
|
||
background: #4a7c59;
|
||
color: white;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn:hover {
|
||
background: #3d6a4b;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: white;
|
||
color: #4a7c59;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
/* Tree container */
|
||
.tree-container {
|
||
max-width: 80%;
|
||
margin: 2rem 0;
|
||
}
|
||
|
||
/* Tree nodes */
|
||
.tree-node {
|
||
margin: 0.5rem 0 0.5rem 1rem;
|
||
border-left: 2px solid #ccc;
|
||
padding-left: 0.75rem;
|
||
}
|
||
|
||
.tree-node-root {
|
||
margin-left: 0;
|
||
border-left: none;
|
||
padding-left: 0;
|
||
}
|
||
|
||
.tree-node.selected {
|
||
background: rgba(74, 124, 89, 0.08);
|
||
outline: 2px solid #4a7c59;
|
||
outline-offset: 4px;
|
||
}
|
||
|
||
.tree-node.kekule {
|
||
border-left-color: #d4af37;
|
||
}
|
||
|
||
.tree-node.kekule.selected {
|
||
background: rgba(212, 175, 55, 0.08);
|
||
outline-color: #d4af37;
|
||
}
|
||
|
||
.person-name {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.person-name a {
|
||
color: var(--link-color);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.person-name a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.person-name .expand-toggle {
|
||
font-family: monospace;
|
||
font-size: 0.9rem;
|
||
color: #888;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.person-name .expand-toggle:hover {
|
||
color: #4a7c59;
|
||
}
|
||
|
||
.person-meta {
|
||
font-size: 0.95rem;
|
||
color: #666;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.person-details {
|
||
font-size: 0.9rem;
|
||
color: #888;
|
||
}
|
||
|
||
.person-details a {
|
||
color: #888;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.person-details a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.person-verse a {
|
||
color: var(--link-color);
|
||
}
|
||
|
||
.children-container {
|
||
display: block;
|
||
}
|
||
|
||
.children-container.collapsed {
|
||
display: none;
|
||
}
|
||
|
||
/* Stats */
|
||
.tree-stats {
|
||
font-size: 0.9rem;
|
||
color: #666;
|
||
margin: 1rem 0;
|
||
}
|
||
|
||
/* Navigation */
|
||
.navigation-links {
|
||
max-width: 55%;
|
||
margin: 2rem 0;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #ccc;
|
||
}
|
||
|
||
.navigation-links a {
|
||
margin-right: 1.5rem;
|
||
}
|
||
|
||
/* Keyboard hint */
|
||
.keyboard-hint {
|
||
max-width: 55%;
|
||
font-size: 0.85rem;
|
||
color: #888;
|
||
margin: 1rem 0;
|
||
}
|
||
|
||
.keyboard-hint kbd {
|
||
font-family: monospace;
|
||
background: #f5f5f5;
|
||
border: 1px solid #ddd;
|
||
border-radius: 3px;
|
||
padding: 0.1rem 0.4rem;
|
||
}
|
||
|
||
/* Dark mode */
|
||
[data-theme="dark"] .tree-header {
|
||
border-bottom-color: #444;
|
||
}
|
||
|
||
[data-theme="dark"] .tree-subtitle,
|
||
[data-theme="dark"] .person-meta,
|
||
[data-theme="dark"] .person-spouse {
|
||
color: #888;
|
||
}
|
||
|
||
[data-theme="dark"] .control-group label {
|
||
color: #aaa;
|
||
}
|
||
|
||
[data-theme="dark"] .control-group select,
|
||
[data-theme="dark"] .control-group input[type="text"] {
|
||
background: #1a1a1a;
|
||
border-color: #444;
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
[data-theme="dark"] .tree-node {
|
||
border-left-color: #444;
|
||
}
|
||
|
||
[data-theme="dark"] .tree-node.selected {
|
||
background: rgba(74, 124, 89, 0.2);
|
||
}
|
||
|
||
[data-theme="dark"] .tree-node.kekule.selected {
|
||
background: rgba(212, 175, 55, 0.15);
|
||
}
|
||
|
||
[data-theme="dark"] .expand-toggle {
|
||
color: #888;
|
||
}
|
||
|
||
[data-theme="dark"] .expand-toggle:hover {
|
||
color: #6b9b7a;
|
||
}
|
||
|
||
[data-theme="dark"] .tree-controls {
|
||
border-bottom-color: #444;
|
||
}
|
||
|
||
[data-theme="dark"] .navigation-links {
|
||
border-top-color: #444;
|
||
}
|
||
|
||
[data-theme="dark"] .keyboard-hint {
|
||
color: #666;
|
||
}
|
||
|
||
[data-theme="dark"] .keyboard-hint kbd {
|
||
background: #2a2a2a;
|
||
border-color: #444;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="tree-header">
|
||
<h1 class="tree-title">Interactive Family Tree</h1>
|
||
<p class="tree-subtitle">From Adam to Jesus Christ</p>
|
||
</div>
|
||
|
||
<div class="intro-text">
|
||
<p><span class="newthought">Explore the biblical genealogy</span> from creation to Christ. Click the arrows to expand or collapse branches, or use keyboard navigation. Names highlighted in gold are direct ancestors of Jesus Christ.</p>
|
||
</div>
|
||
|
||
<div class="tree-controls">
|
||
<div class="control-group">
|
||
<label for="root-select">Start from:</label>
|
||
<select id="root-select">
|
||
<option value="adam">Adam</option>
|
||
<option value="noah">Noah</option>
|
||
<option value="abraham">Abraham</option>
|
||
<option value="jacob">Jacob (Israel)</option>
|
||
<option value="david">David</option>
|
||
<option value="jesus">Jesus (ancestors)</option>
|
||
</select>
|
||
</div>
|
||
<div class="control-group">
|
||
<label for="search-input">Search:</label>
|
||
<input type="text" id="search-input" placeholder="Find a person...">
|
||
</div>
|
||
<button class="btn btn-secondary" id="expand-all">Expand All</button>
|
||
<button class="btn btn-secondary" id="collapse-all">Collapse All</button>
|
||
</div>
|
||
|
||
<div class="tree-stats" id="tree-stats"></div>
|
||
|
||
<div class="tree-container" id="tree-container">
|
||
<div class="tree-loading">Loading family tree...</div>
|
||
</div>
|
||
|
||
<div class="keyboard-hint">
|
||
<kbd>↑</kbd><kbd>↓</kbd> navigate · <kbd>←</kbd><kbd>→</kbd> collapse/expand · <kbd>Enter</kbd> view person · <kbd>e</kbd> expand all · <kbd>c</kbd> collapse all
|
||
</div>
|
||
|
||
<div class="navigation-links">
|
||
<a href="/family-tree">← Family Tree</a>
|
||
<a href="/family-tree/lineage">Messianic Lineage</a>
|
||
</div>
|
||
|
||
<script>
|
||
const familyTreeData = {{ family_tree_data | tojson }};
|
||
|
||
let currentRoot = 'adam';
|
||
let collapsedNodes = new Set();
|
||
let expandedNodes = new Set(); // Manually expanded nodes (override depth limit)
|
||
let selectedIndex = -1;
|
||
let visibleNodes = [];
|
||
let defaultDepth = 2; // Show 2 levels by default
|
||
|
||
// Find person ID 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;
|
||
}
|
||
|
||
// Get root mappings
|
||
const rootMappings = {
|
||
'adam': 'Adam',
|
||
'noah': 'Noah',
|
||
'abraham': 'Abraham',
|
||
'jacob': 'Jacob',
|
||
'david': 'David',
|
||
'jesus': 'Jesus'
|
||
};
|
||
|
||
// Build tree HTML recursively
|
||
function buildTreeHTML(personId, depth = 0, visited = new Set(), isRoot = false) {
|
||
if (visited.has(personId) || depth > 50) return '';
|
||
visited.add(personId);
|
||
|
||
const person = familyTreeData[personId];
|
||
if (!person) return '';
|
||
|
||
const children = person.children || [];
|
||
const hasChildren = children.length > 0;
|
||
// Auto-collapse nodes beyond default depth on initial render
|
||
const isCollapsed = collapsedNodes.has(personId) || (depth >= defaultDepth && hasChildren && !expandedNodes.has(personId));
|
||
const hasKekule = person.kekule_number !== null && person.kekule_number !== undefined;
|
||
|
||
let html = `<div class="tree-node${isRoot ? ' tree-node-root' : ''}${hasKekule ? ' kekule' : ''}" data-id="${personId}">`;
|
||
|
||
// Name line with expand toggle showing child count
|
||
html += '<div class="person-name">';
|
||
html += `<a href="/family-tree/person/${personId}">${person.name}</a>`;
|
||
if (hasChildren) {
|
||
html += `<span class="expand-toggle" data-id="${personId}">${isCollapsed ? '+' : '−'}</span>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
// Generation line
|
||
if (person.generation) {
|
||
html += `<div class="person-meta">Generation ${person.generation} from Adam</div>`;
|
||
}
|
||
|
||
// Details line (lifespan, children, spouse, verse)
|
||
let details = [];
|
||
|
||
// Extract lifespan from death_year if it contains "Lived X years"
|
||
const lifespanMatch = person.death_year && person.death_year.match(/Lived (\d+) years/);
|
||
if (lifespanMatch) {
|
||
details.push(`Lived ${lifespanMatch[1]} years`);
|
||
}
|
||
|
||
// Number of children
|
||
if (children.length > 0) {
|
||
details.push(`${children.length} ${children.length > 1 ? 'children' : 'child'}`);
|
||
}
|
||
|
||
// Spouse
|
||
if (person.spouse) {
|
||
const spouseId = findPersonId(person.spouse);
|
||
if (spouseId) {
|
||
details.push(`∞ <a href="/family-tree/person/${spouseId}">${person.spouse}</a>`);
|
||
} else {
|
||
details.push(`∞ ${person.spouse}`);
|
||
}
|
||
}
|
||
|
||
// Scripture reference
|
||
if (person.verses && person.verses.length > 0) {
|
||
const verse = person.verses[0];
|
||
const refMatch = verse.reference.match(/^(\d?\s*[A-Za-z]+)\s+(\d+):(\d+)/);
|
||
if (refMatch) {
|
||
const book = refMatch[1].trim();
|
||
const chapter = refMatch[2];
|
||
const verseNum = refMatch[3];
|
||
const verseUrl = `/book/${encodeURIComponent(book)}/chapter/${chapter}#verse-${verseNum}`;
|
||
details.push(`<span class="person-verse"><a href="${verseUrl}">${verse.reference}</a></span>`);
|
||
}
|
||
}
|
||
|
||
if (details.length > 0) {
|
||
html += `<div class="person-details">${details.join(' · ')}</div>`;
|
||
}
|
||
|
||
// Children
|
||
if (hasChildren) {
|
||
html += `<div class="children-container${isCollapsed ? ' collapsed' : ''}" data-parent="${personId}">`;
|
||
for (const childId of children) {
|
||
html += buildTreeHTML(childId, depth + 1, visited);
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
// Build ancestors tree (for Jesus view)
|
||
function buildAncestorsHTML(personId, depth = 0, visited = new Set(), isRoot = false) {
|
||
if (visited.has(personId) || depth > 50) return '';
|
||
visited.add(personId);
|
||
|
||
const person = familyTreeData[personId];
|
||
if (!person) return '';
|
||
|
||
const parents = person.parents || [];
|
||
const hasParents = parents.length > 0;
|
||
// Auto-collapse nodes beyond default depth on initial render
|
||
const isCollapsed = collapsedNodes.has(personId) || (depth >= defaultDepth && hasParents && !expandedNodes.has(personId));
|
||
const hasKekule = person.kekule_number !== null && person.kekule_number !== undefined;
|
||
|
||
let html = `<div class="tree-node${isRoot ? ' tree-node-root' : ''}${hasKekule ? ' kekule' : ''}" data-id="${personId}">`;
|
||
|
||
// Name line with expand toggle showing parent count
|
||
html += '<div class="person-name">';
|
||
html += `<a href="/family-tree/person/${personId}">${person.name}</a>`;
|
||
if (hasParents) {
|
||
html += `<span class="expand-toggle" data-id="${personId}">${isCollapsed ? '+' : '−'}</span>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
// Generation line
|
||
if (person.generation) {
|
||
html += `<div class="person-meta">Generation ${person.generation} from Adam</div>`;
|
||
}
|
||
|
||
// Details line (lifespan, children count, spouse, verse)
|
||
let details = [];
|
||
|
||
// Extract lifespan from death_year if it contains "Lived X years"
|
||
const lifespanMatch = person.death_year && person.death_year.match(/Lived (\d+) years/);
|
||
if (lifespanMatch) {
|
||
details.push(`Lived ${lifespanMatch[1]} years`);
|
||
}
|
||
|
||
// Number of children
|
||
const children = person.children || [];
|
||
if (children.length > 0) {
|
||
details.push(`${children.length} ${children.length > 1 ? 'children' : 'child'}`);
|
||
}
|
||
|
||
// Spouse
|
||
if (person.spouse) {
|
||
const spouseId = findPersonId(person.spouse);
|
||
if (spouseId) {
|
||
details.push(`∞ <a href="/family-tree/person/${spouseId}">${person.spouse}</a>`);
|
||
} else {
|
||
details.push(`∞ ${person.spouse}`);
|
||
}
|
||
}
|
||
|
||
// Scripture reference
|
||
if (person.verses && person.verses.length > 0) {
|
||
const verse = person.verses[0];
|
||
const refMatch = verse.reference.match(/^(\d?\s*[A-Za-z]+)\s+(\d+):(\d+)/);
|
||
if (refMatch) {
|
||
const book = refMatch[1].trim();
|
||
const chapter = refMatch[2];
|
||
const verseNum = refMatch[3];
|
||
const verseUrl = `/book/${encodeURIComponent(book)}/chapter/${chapter}#verse-${verseNum}`;
|
||
details.push(`<span class="person-verse"><a href="${verseUrl}">${verse.reference}</a></span>`);
|
||
}
|
||
}
|
||
|
||
if (details.length > 0) {
|
||
html += `<div class="person-details">${details.join(' · ')}</div>`;
|
||
}
|
||
|
||
if (hasParents) {
|
||
html += `<div class="children-container${isCollapsed ? ' collapsed' : ''}" data-parent="${personId}">`;
|
||
for (const parentId of parents) {
|
||
html += buildAncestorsHTML(parentId, depth + 1, visited);
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
// Render tree
|
||
function renderTree() {
|
||
const container = document.getElementById('tree-container');
|
||
const rootName = rootMappings[currentRoot];
|
||
let rootId;
|
||
|
||
if (currentRoot === 'jesus') {
|
||
rootId = findPersonId('Jesus') || findPersonId('Jesus Christ');
|
||
} else if (currentRoot === 'jacob') {
|
||
// Find Jacob/Israel specifically
|
||
for (const [id, person] of Object.entries(familyTreeData)) {
|
||
if (person.name === 'Jacob' && person.generation) {
|
||
rootId = id;
|
||
break;
|
||
}
|
||
}
|
||
if (!rootId) rootId = findPersonId('Jacob');
|
||
} else {
|
||
rootId = findPersonId(rootName);
|
||
}
|
||
|
||
if (!rootId) {
|
||
container.innerHTML = '<p>Person not found.</p>';
|
||
return;
|
||
}
|
||
|
||
let html;
|
||
if (currentRoot === 'jesus') {
|
||
html = buildAncestorsHTML(rootId, 0, new Set(), true);
|
||
} else {
|
||
html = buildTreeHTML(rootId, 0, new Set(), true);
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Update stats
|
||
const nodeCount = container.querySelectorAll('.tree-node').length;
|
||
document.getElementById('tree-stats').textContent = `Showing ${nodeCount} people`;
|
||
|
||
// Update visible nodes for keyboard nav
|
||
updateVisibleNodes();
|
||
|
||
// Add click handlers for expand toggles
|
||
container.querySelectorAll('.expand-toggle:not(.empty)').forEach(toggle => {
|
||
toggle.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const id = toggle.dataset.id;
|
||
toggleNode(id);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Toggle node expand/collapse
|
||
function toggleNode(personId) {
|
||
const toggle = document.querySelector(`.expand-toggle[data-id="${personId}"]`);
|
||
const children = document.querySelector(`.children-container[data-parent="${personId}"]`);
|
||
const isCurrentlyCollapsed = children && children.classList.contains('collapsed');
|
||
|
||
if (isCurrentlyCollapsed) {
|
||
// Expanding
|
||
collapsedNodes.delete(personId);
|
||
expandedNodes.add(personId);
|
||
} else {
|
||
// Collapsing
|
||
collapsedNodes.add(personId);
|
||
expandedNodes.delete(personId);
|
||
}
|
||
|
||
// Update the UI
|
||
if (toggle && children) {
|
||
const shouldCollapse = !isCurrentlyCollapsed;
|
||
toggle.textContent = shouldCollapse ? '+' : '−';
|
||
children.classList.toggle('collapsed', shouldCollapse);
|
||
}
|
||
|
||
updateVisibleNodes();
|
||
}
|
||
|
||
// Update list of visible nodes for keyboard navigation
|
||
function updateVisibleNodes() {
|
||
visibleNodes = Array.from(document.querySelectorAll('.tree-node')).filter(node => {
|
||
// Check if any ancestor is collapsed
|
||
let parent = node.parentElement;
|
||
while (parent) {
|
||
if (parent.classList.contains('collapsed')) {
|
||
return false;
|
||
}
|
||
parent = parent.parentElement;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// Select node by index
|
||
function selectNode(index) {
|
||
// Deselect previous
|
||
if (selectedIndex >= 0 && selectedIndex < visibleNodes.length) {
|
||
visibleNodes[selectedIndex].classList.remove('selected');
|
||
}
|
||
|
||
selectedIndex = Math.max(0, Math.min(index, visibleNodes.length - 1));
|
||
|
||
if (selectedIndex >= 0 && selectedIndex < visibleNodes.length) {
|
||
visibleNodes[selectedIndex].classList.add('selected');
|
||
visibleNodes[selectedIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
}
|
||
|
||
// Expand all nodes
|
||
function expandAll() {
|
||
collapsedNodes.clear();
|
||
// Mark all as manually expanded to override depth limit
|
||
for (const id of Object.keys(familyTreeData)) {
|
||
expandedNodes.add(id);
|
||
}
|
||
renderTree();
|
||
}
|
||
|
||
// Collapse all nodes
|
||
function collapseAll() {
|
||
expandedNodes.clear();
|
||
collapsedNodes.clear();
|
||
// Just re-render - depth limit will collapse everything beyond defaultDepth
|
||
renderTree();
|
||
}
|
||
|
||
// Search functionality
|
||
let searchTimeout;
|
||
document.getElementById('search-input').addEventListener('input', (e) => {
|
||
clearTimeout(searchTimeout);
|
||
const query = e.target.value.trim().toLowerCase();
|
||
|
||
if (!query) return;
|
||
|
||
searchTimeout = setTimeout(() => {
|
||
// Find matching person
|
||
const personId = findPersonId(query);
|
||
if (personId) {
|
||
// Expand path to this person
|
||
expandPathTo(personId);
|
||
|
||
// Find and select the node
|
||
updateVisibleNodes();
|
||
const nodeIndex = visibleNodes.findIndex(n => n.dataset.id === personId);
|
||
if (nodeIndex >= 0) {
|
||
selectNode(nodeIndex);
|
||
}
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
// Expand path from root to a specific person
|
||
function expandPathTo(targetId) {
|
||
const person = familyTreeData[targetId];
|
||
if (!person) return;
|
||
|
||
// For descendants view, expand all ancestors of the target (walk up to root)
|
||
if (currentRoot !== 'jesus') {
|
||
const parents = person.parents || [];
|
||
for (const parentId of parents) {
|
||
expandedNodes.add(parentId);
|
||
collapsedNodes.delete(parentId);
|
||
expandPathTo(parentId);
|
||
}
|
||
} else {
|
||
// For ancestors view, expand children path (walk down to root)
|
||
const children = person.children || [];
|
||
for (const childId of children) {
|
||
expandedNodes.add(childId);
|
||
collapsedNodes.delete(childId);
|
||
expandPathTo(childId);
|
||
}
|
||
}
|
||
|
||
expandedNodes.add(targetId);
|
||
collapsedNodes.delete(targetId);
|
||
renderTree();
|
||
}
|
||
|
||
// Root selector
|
||
document.getElementById('root-select').addEventListener('change', (e) => {
|
||
currentRoot = e.target.value;
|
||
collapsedNodes.clear();
|
||
expandedNodes.clear();
|
||
selectedIndex = -1;
|
||
renderTree();
|
||
});
|
||
|
||
// Expand/collapse all buttons
|
||
document.getElementById('expand-all').addEventListener('click', expandAll);
|
||
document.getElementById('collapse-all').addEventListener('click', collapseAll);
|
||
|
||
// Keyboard navigation
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
||
return;
|
||
}
|
||
// Don't handle if sidebar navigation is active
|
||
if (KJVNav.sidebarActive) return;
|
||
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
case 'j':
|
||
e.preventDefault();
|
||
selectNode(selectedIndex < 0 ? 0 : selectedIndex + 1);
|
||
break;
|
||
|
||
case 'ArrowUp':
|
||
case 'k':
|
||
e.preventDefault();
|
||
selectNode(selectedIndex <= 0 ? 0 : selectedIndex - 1);
|
||
break;
|
||
|
||
case 'ArrowRight':
|
||
case 'l':
|
||
e.preventDefault();
|
||
if (selectedIndex >= 0 && selectedIndex < visibleNodes.length) {
|
||
const id = visibleNodes[selectedIndex].dataset.id;
|
||
const childContainer = document.querySelector(`.children-container[data-parent="${id}"]`);
|
||
// Expand if currently collapsed
|
||
if (childContainer && childContainer.classList.contains('collapsed')) {
|
||
toggleNode(id);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'ArrowLeft':
|
||
case 'h':
|
||
e.preventDefault();
|
||
if (selectedIndex >= 0 && selectedIndex < visibleNodes.length) {
|
||
const id = visibleNodes[selectedIndex].dataset.id;
|
||
const childContainer = document.querySelector(`.children-container[data-parent="${id}"]`);
|
||
// Collapse if currently expanded
|
||
if (childContainer && !childContainer.classList.contains('collapsed')) {
|
||
toggleNode(id);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'Enter':
|
||
e.preventDefault();
|
||
if (selectedIndex >= 0 && selectedIndex < visibleNodes.length) {
|
||
const link = visibleNodes[selectedIndex].querySelector('.person-name a');
|
||
if (link) window.location.href = link.href;
|
||
}
|
||
break;
|
||
|
||
case 'e':
|
||
e.preventDefault();
|
||
expandAll();
|
||
break;
|
||
|
||
case 'c':
|
||
e.preventDefault();
|
||
collapseAll();
|
||
break;
|
||
|
||
case 'Escape':
|
||
e.preventDefault();
|
||
if (selectedIndex >= 0 && selectedIndex < visibleNodes.length) {
|
||
visibleNodes[selectedIndex].classList.remove('selected');
|
||
}
|
||
selectedIndex = -1;
|
||
break;
|
||
}
|
||
});
|
||
|
||
// Find best root for a given person (nearest "save point" ancestor)
|
||
function findBestRoot(personId) {
|
||
const rootOrder = ['david', 'jacob', 'abraham', 'noah', 'adam'];
|
||
const rootIds = {};
|
||
|
||
// Find IDs for each root
|
||
for (const rootName of rootOrder) {
|
||
const id = findPersonId(rootMappings[rootName] || rootName);
|
||
if (id) rootIds[rootName] = id;
|
||
}
|
||
|
||
// Check if person is a descendant of each root (starting from most specific)
|
||
for (const rootName of rootOrder) {
|
||
const rootId = rootIds[rootName];
|
||
if (!rootId) continue;
|
||
|
||
// Check if personId is a descendant of rootId
|
||
if (isDescendantOf(personId, rootId)) {
|
||
return rootName;
|
||
}
|
||
}
|
||
|
||
return 'adam'; // Default fallback
|
||
}
|
||
|
||
// Check if personId is a descendant of ancestorId
|
||
function isDescendantOf(personId, ancestorId, visited = new Set()) {
|
||
if (personId === ancestorId) return true;
|
||
if (visited.has(personId)) return false;
|
||
visited.add(personId);
|
||
|
||
const person = familyTreeData[personId];
|
||
if (!person || !person.parents) return false;
|
||
|
||
for (const parentId of person.parents) {
|
||
if (isDescendantOf(parentId, ancestorId, visited)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Handle URL parameters (e.g., ?person=i77)
|
||
function handleUrlParams() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const personId = params.get('person');
|
||
|
||
if (personId && familyTreeData[personId]) {
|
||
// Find best root for this person
|
||
const bestRoot = findBestRoot(personId);
|
||
if (bestRoot !== currentRoot) {
|
||
currentRoot = bestRoot;
|
||
document.getElementById('root-select').value = bestRoot;
|
||
renderTree();
|
||
}
|
||
|
||
// Expand path to this person and select them
|
||
expandPathTo(personId);
|
||
updateVisibleNodes();
|
||
const nodeIndex = visibleNodes.findIndex(n => n.dataset.id === personId);
|
||
if (nodeIndex >= 0) {
|
||
selectNode(nodeIndex);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initial render
|
||
renderTree();
|
||
handleUrlParams();
|
||
</script>
|
||
{% endblock %}
|