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