Files
kjvstudy.org/kjvstudy_org/templates/family_tree_interactive.html
T
kennethreitz 3336863a4d Improve keyboard navigation consistency across site
- 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>
2025-12-03 01:26:09 -05:00

871 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}