Add interactive timeline visualization for biblical lifespans

- Create timeline page showing when biblical figures lived
- Display horizontal bars for each person's lifespan
- Filter by era (Antediluvian, Patriarchs, Judges, Kings, Exile)
- Filter to show Messianic line only or all people
- Golden highlighting for Christ's ancestors (Kekulé numbers)
- Hover tooltips with detailed person info
- Click to navigate to person's profile
- Zoom and pan support
- Update family tree page with prominent links to visualizations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-29 14:57:24 -05:00
parent f38f88ed5d
commit f4d2bf8ede
3 changed files with 653 additions and 13 deletions
+27
View File
@@ -617,6 +617,33 @@ def family_tree_lineage_page(request: Request):
)
@router.get("/family-tree/timeline", response_class=HTMLResponse)
def family_tree_timeline_page(request: Request):
"""Interactive timeline visualization of biblical lifespans."""
family_tree_data, generations = get_family_tree_data()
if not family_tree_data:
raise HTTPException(
status_code=500,
detail="Family tree data not available"
)
return templates.TemplateResponse(
request,
"family_tree_timeline.html",
{
"books": get_books(),
"family_tree_data": family_tree_data,
"generations": generations,
"breadcrumbs": [
{"text": "Home", "url": "/"},
{"text": "Family Tree", "url": "/family-tree"},
{"text": "Timeline", "url": None}
]
}
)
@router.get("/family-tree/person/{person_id}/descendants", response_class=HTMLResponse)
def family_tree_descendants_page(request: Request, person_id: str):
"""View all descendants of a person."""
+10 -13
View File
@@ -194,21 +194,18 @@ section:nth-of-type(3) {
</section>
<section>
<h2>Quick Links</h2>
<p>
<a href="/family-tree/lineage">View the Messianic Lineage</a> — A visual genealogy showing the direct paternal line from Adam to Jesus Christ.
<h2>Explore the Family Tree</h2>
<p style="font-size: 1.1rem; line-height: 1.8; margin-bottom: 1.5rem;">
<strong><a href="/family-tree/interactive">Interactive Tree</a></strong>
Navigate the family tree with an interactive, zoomable diagram. Expand and collapse branches, search for any person, and see the Messianic lineage highlighted in gold.
</p>
</section>
<section>
<h2>Visualizations</h2>
<p>
<a href="/family-tree/interactive">Interactive Tree Visualization</a>
Explore the family tree with an interactive, zoomable diagram.
<p style="font-size: 1.1rem; line-height: 1.8; margin-bottom: 1.5rem;">
<strong><a href="/family-tree/timeline">Lifespan Timeline</a></strong>
See when biblical figures lived on a visual timeline. Discover how the extraordinary lifespans of early patriarchs overlapped across generations.
</p>
<p>
<a href="/family-tree/lineage">Messianic Lineage</a>
View the direct paternal line from Adam to Jesus Christ.
<p style="font-size: 1.1rem; line-height: 1.8;">
<strong><a href="/family-tree/lineage">Messianic Lineage</a></strong>
View the direct paternal line from Adam to Jesus Christ in a vertical genealogy chart.
</p>
</section>
@@ -0,0 +1,616 @@
{% extends "base.html" %}
{% block title %}Biblical Timeline - KJV Study{% endblock %}
{% block description %}Explore biblical lifespans from Adam to Jesus Christ on an interactive timeline.{% endblock %}
{% block head %}
<style>
/* Main container */
.timeline-page {
max-width: 100%;
padding: 0;
}
.timeline-header {
max-width: 55%;
margin-bottom: 1.5rem;
}
.timeline-header h1 {
margin-bottom: 0.5rem;
}
.timeline-header p {
color: #666;
margin: 0;
}
/* Controls bar */
.timeline-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-divider {
width: 1px;
height: 24px;
background: #ddd;
margin: 0 0.5rem;
}
/* Timeline viewport */
.timeline-viewport {
position: relative;
width: 100%;
height: 75vh;
min-height: 600px;
background: #fcfcfc;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
#timeline-container {
width: 100%;
height: 100%;
}
/* Lifespan bars */
.lifespan-bar {
cursor: pointer;
transition: opacity 0.15s ease;
}
.lifespan-bar:hover {
opacity: 0.85;
}
.lifespan-bar.kekule {
stroke: #d4af37;
stroke-width: 2;
}
/* Person labels */
.person-label {
font-family: "ETBembo", Palatino, "Book Antiqua", serif;
font-size: 12px;
fill: #333;
cursor: pointer;
}
.person-label:hover {
fill: #000;
font-weight: 600;
}
/* Year axis */
.year-axis text {
font-family: "ETBembo", Palatino, "Book Antiqua", serif;
font-size: 11px;
fill: #666;
}
.year-axis line,
.year-axis path {
stroke: #ccc;
}
/* Era labels */
.era-label {
font-family: "ETBembo", Palatino, "Book Antiqua", serif;
font-size: 13px;
fill: #888;
font-style: italic;
}
.era-band {
opacity: 0.1;
}
/* Info tooltip */
.timeline-tooltip {
position: absolute;
padding: 0.75rem 1rem;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 100;
max-width: 280px;
}
.timeline-tooltip.visible {
opacity: 1;
}
.tooltip-name {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.tooltip-dates {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.tooltip-lifespan {
font-style: italic;
color: #888;
font-size: 0.85rem;
}
.tooltip-kekule {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #eee;
font-size: 0.85rem;
color: #d4af37;
}
/* Legend */
.timeline-legend {
position: absolute;
bottom: 1rem;
left: 1rem;
padding: 0.75rem 1rem;
background: rgba(255,255,255,0.95);
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.85rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 50;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.25rem 0;
}
.legend-color {
width: 20px;
height: 12px;
border-radius: 2px;
}
/* Back link */
.back-link {
margin-top: 1.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.timeline-controls {
flex-direction: column;
align-items: stretch;
}
.control-divider {
display: none;
}
}
/* Loading state */
.timeline-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 1.1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="timeline-page">
<div class="timeline-header">
<h1>Biblical Timeline</h1>
<p>Visualize the extraordinary lifespans of biblical figures and see who lived at the same time.</p>
</div>
<div class="timeline-controls">
<div class="control-group">
<label for="era-select">Era:</label>
<select id="era-select">
<option value="all">All Eras</option>
<option value="antediluvian" selected>Antediluvian (Before Flood)</option>
<option value="patriarchs">Patriarchs (Abraham to Joseph)</option>
<option value="judges">Judges Period</option>
<option value="kings">Kingdom Era</option>
<option value="exile">Exile & Return</option>
</select>
</div>
<div class="control-divider"></div>
<div class="control-group">
<label for="filter-select">Show:</label>
<select id="filter-select">
<option value="kekule">Messianic Line Only</option>
<option value="all">All People</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>
</div>
</div>
<div class="timeline-viewport">
<div id="timeline-container">
<div class="timeline-loading">Loading timeline...</div>
</div>
<div class="timeline-tooltip" id="timeline-tooltip">
<div class="tooltip-name" id="tooltip-name"></div>
<div class="tooltip-dates" id="tooltip-dates"></div>
<div class="tooltip-lifespan" id="tooltip-lifespan"></div>
<div class="tooltip-kekule" id="tooltip-kekule"></div>
</div>
<div class="timeline-legend">
<div class="legend-item">
<div class="legend-color" style="background: #d4af37;"></div>
<span>Messianic Line (Kekulé ancestor)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #3b6ea5;"></div>
<span>Male</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #a55b80;"></div>
<span>Female</span>
</div>
</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 }};
// State
let currentEra = 'antediluvian';
let currentFilter = 'kekule';
let transform = d3.zoomIdentity;
// DOM elements
const container = document.getElementById('timeline-container');
const tooltip = document.getElementById('timeline-tooltip');
// Era definitions (approximate years from creation)
const eras = {
'antediluvian': { start: 0, end: 1656, label: 'Antediluvian Period' },
'patriarchs': { start: 1948, end: 2369, label: 'Patriarchal Period' },
'judges': { start: 2513, end: 2909, label: 'Judges Period' },
'kings': { start: 2909, end: 3442, label: 'Kingdom Era' },
'exile': { start: 3338, end: 3800, label: 'Exile & Return' },
'all': { start: 0, end: 4000, label: 'All Biblical History' }
};
// Female names for gender inference
const femaleNames = ['eve', 'sarah', 'rebekah', 'rachel', 'leah', 'ruth', 'mary', 'tamar', 'rahab', 'bathsheba', 'dinah', 'keturah', 'hagar', 'zilpah', 'bilhah', 'jochebed', 'miriam', 'deborah', 'hannah', 'abigail', 'esther', 'naomi'];
function inferGender(name) {
return femaleNames.some(f => name.toLowerCase().includes(f)) ? 'female' : 'male';
}
// Parse birth year from string (handles "Anno Mundi" format)
function parseBirthYear(yearStr) {
if (!yearStr || yearStr === 'Unknown') return null;
// Try to extract numeric year
const match = yearStr.match(/(\d+)/);
if (match) {
return parseInt(match[1]);
}
return null;
}
// Get people for timeline
function getTimelinePeople() {
const era = eras[currentEra];
const people = [];
for (const [id, person] of Object.entries(familyTreeData)) {
const birthYear = parseBirthYear(person.birth_year);
if (birthYear === null) continue;
// Parse age/lifespan
let lifespan = 0;
if (person.age_at_death && person.age_at_death !== 'Unknown') {
const ageMatch = person.age_at_death.match(/(\d+)/);
if (ageMatch) {
lifespan = parseInt(ageMatch[1]);
}
}
const deathYear = lifespan > 0 ? birthYear + lifespan : birthYear + 70; // Default 70 if unknown
// Filter by era
if (currentEra !== 'all') {
if (deathYear < era.start || birthYear > era.end) continue;
}
// Filter by Kekulé status
const hasKekule = person.kekule_number !== null && person.kekule_number !== undefined;
if (currentFilter === 'kekule' && !hasKekule) continue;
people.push({
id,
name: person.name,
birthYear,
deathYear,
lifespan,
generation: person.generation,
kekule_number: person.kekule_number,
gender: inferGender(person.name),
hasKekule
});
}
// Sort by birth year
people.sort((a, b) => a.birthYear - b.birthYear);
return people;
}
// Render timeline
function renderTimeline() {
container.innerHTML = '';
const people = getTimelinePeople();
if (people.length === 0) {
container.innerHTML = '<div class="timeline-loading">No people found for selected filters</div>';
return;
}
const era = eras[currentEra];
const margin = { top: 60, right: 40, bottom: 40, left: 150 };
const width = container.clientWidth - margin.left - margin.right;
const height = Math.max(container.clientHeight - margin.top - margin.bottom, people.length * 28);
// Create SVG
const svg = d3.select(container)
.append('svg')
.attr('width', container.clientWidth)
.attr('height', container.clientHeight);
const g = svg.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
// Zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.5, 5])
.on('zoom', (event) => {
transform = event.transform;
g.attr('transform', `translate(${margin.left + event.transform.x}, ${margin.top + event.transform.y}) scale(${event.transform.k})`);
});
svg.call(zoom);
// Scales
const xScale = d3.scaleLinear()
.domain([era.start, era.end])
.range([0, width]);
const yScale = d3.scaleBand()
.domain(people.map(p => p.id))
.range([0, height])
.padding(0.2);
// X axis (years)
const xAxis = d3.axisTop(xScale)
.ticks(10)
.tickFormat(d => `${d} AM`);
g.append('g')
.attr('class', 'year-axis')
.call(xAxis);
// Era label
g.append('text')
.attr('class', 'era-label')
.attr('x', width / 2)
.attr('y', -40)
.attr('text-anchor', 'middle')
.text(era.label);
// Grid lines
g.append('g')
.attr('class', 'grid-lines')
.selectAll('line')
.data(xScale.ticks(10))
.join('line')
.attr('x1', d => xScale(d))
.attr('x2', d => xScale(d))
.attr('y1', 0)
.attr('y2', height)
.attr('stroke', '#eee')
.attr('stroke-width', 1);
// Lifespan bars
const bars = g.selectAll('.lifespan-bar')
.data(people)
.join('rect')
.attr('class', d => `lifespan-bar ${d.hasKekule ? 'kekule' : ''}`)
.attr('x', d => xScale(Math.max(d.birthYear, era.start)))
.attr('y', d => yScale(d.id))
.attr('width', d => {
const start = Math.max(d.birthYear, era.start);
const end = Math.min(d.deathYear, era.end);
return Math.max(0, xScale(end) - xScale(start));
})
.attr('height', yScale.bandwidth())
.attr('rx', 3)
.attr('fill', d => {
if (d.hasKekule) return '#d4af37';
return d.gender === 'female' ? '#a55b80' : '#3b6ea5';
})
.attr('opacity', 0.8)
.on('mouseenter', (event, d) => showTooltip(event, d))
.on('mousemove', (event, d) => moveTooltip(event))
.on('mouseleave', hideTooltip)
.on('click', (event, d) => {
window.location.href = `/family-tree/person/${d.id}`;
});
// Person labels
g.selectAll('.person-label')
.data(people)
.join('text')
.attr('class', 'person-label')
.attr('x', -10)
.attr('y', d => yScale(d.id) + yScale.bandwidth() / 2)
.attr('text-anchor', 'end')
.attr('dominant-baseline', 'middle')
.text(d => d.name)
.on('click', (event, d) => {
window.location.href = `/family-tree/person/${d.id}`;
});
// Store zoom for controls
window.timelineZoom = zoom;
window.timelineSvg = svg;
}
// Tooltip functions
function showTooltip(event, d) {
document.getElementById('tooltip-name').textContent = d.name;
document.getElementById('tooltip-dates').textContent = `${d.birthYear} AM ${d.deathYear} AM`;
document.getElementById('tooltip-lifespan').textContent = d.lifespan > 0 ? `Lived ${d.lifespan} years` : 'Lifespan unknown';
const kekuleEl = document.getElementById('tooltip-kekule');
if (d.hasKekule) {
kekuleEl.textContent = `Kekulé #${d.kekule_number} Ancestor of Christ`;
kekuleEl.style.display = 'block';
} else {
kekuleEl.style.display = 'none';
}
tooltip.classList.add('visible');
moveTooltip(event);
}
function moveTooltip(event) {
const rect = container.getBoundingClientRect();
let x = event.clientX - rect.left + 15;
let y = event.clientY - rect.top + 15;
// Keep tooltip within viewport
if (x + 280 > rect.width) x = event.clientX - rect.left - 290;
if (y + 150 > rect.height) y = event.clientY - rect.top - 160;
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
}
function hideTooltip() {
tooltip.classList.remove('visible');
}
// Controls
document.getElementById('era-select').addEventListener('change', (e) => {
currentEra = e.target.value;
transform = d3.zoomIdentity;
renderTimeline();
});
document.getElementById('filter-select').addEventListener('change', (e) => {
currentFilter = e.target.value;
transform = d3.zoomIdentity;
renderTimeline();
});
document.getElementById('zoom-in').addEventListener('click', () => {
if (window.timelineSvg && window.timelineZoom) {
window.timelineSvg.transition().duration(200).call(window.timelineZoom.scaleBy, 1.4);
}
});
document.getElementById('zoom-out').addEventListener('click', () => {
if (window.timelineSvg && window.timelineZoom) {
window.timelineSvg.transition().duration(200).call(window.timelineZoom.scaleBy, 0.7);
}
});
document.getElementById('zoom-reset').addEventListener('click', () => {
if (window.timelineSvg && window.timelineZoom) {
window.timelineSvg.transition().duration(300).call(window.timelineZoom.transform, d3.zoomIdentity);
}
});
// Handle window resize
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(renderTimeline, 200);
});
// Initial render
renderTimeline();
</script>
{% endblock %}