mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
Add FamilySearch-style interactive family tree
The commit adds a new FamilySearch-style interactive family tree visualization with person cards, smooth animations, and hierarchical layout. Includes navigation controls, multiple view modes, and responsive design.
This commit is contained in:
@@ -0,0 +1,741 @@
|
||||
/**
|
||||
* FamilySearch-Style Interactive Family Tree
|
||||
* Recreates the FamilySearch family tree experience with person cards,
|
||||
* smooth animations, and their signature layout
|
||||
*/
|
||||
|
||||
class FamilySearchStyleTree {
|
||||
constructor(containerId, familyData) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.familyData = familyData;
|
||||
this.currentPersonId = null;
|
||||
this.treeData = {};
|
||||
this.scale = 1;
|
||||
this.translateX = 0;
|
||||
this.translateY = 0;
|
||||
this.isDragging = false;
|
||||
|
||||
this.cardWidth = 200;
|
||||
this.cardHeight = 120;
|
||||
this.generationSpacing = 180;
|
||||
this.siblingSpacing = 220;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createTreeContainer();
|
||||
this.setupEventListeners();
|
||||
this.initializeWithFirstPerson();
|
||||
}
|
||||
|
||||
createTreeContainer() {
|
||||
this.container.innerHTML = `
|
||||
<div class="fs-tree-wrapper">
|
||||
<div class="fs-tree-controls">
|
||||
<div class="fs-control-group">
|
||||
<button class="fs-btn fs-btn-icon" onclick="fsTree.zoomIn()" title="Zoom In">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button class="fs-btn fs-btn-icon" onclick="fsTree.zoomOut()" title="Zoom Out">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
<button class="fs-btn fs-btn-icon" onclick="fsTree.centerTree()" title="Center">
|
||||
<i class="fas fa-crosshairs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="fs-control-group">
|
||||
<button class="fs-btn fs-btn-compact" onclick="fsTree.switchView('ancestors')" title="Focus on Ancestors">
|
||||
<i class="fas fa-arrow-up"></i> Ancestors
|
||||
</button>
|
||||
<button class="fs-btn fs-btn-compact" onclick="fsTree.switchView('family')" title="Focus on Family">
|
||||
<i class="fas fa-users"></i> Family
|
||||
</button>
|
||||
<button class="fs-btn fs-btn-compact" onclick="fsTree.switchView('descendants')" title="Focus on Descendants">
|
||||
<i class="fas fa-arrow-down"></i> Descendants
|
||||
</button>
|
||||
</div>
|
||||
<div class="fs-control-group">
|
||||
<div class="fs-breadcrumb">
|
||||
<span id="fs-breadcrumb-text">Biblical Family Tree</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fs-tree-viewport" id="fs-tree-viewport">
|
||||
<svg class="fs-tree-svg" id="fs-tree-svg">
|
||||
<defs>
|
||||
<filter id="fs-shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000" flood-opacity="0.1"/>
|
||||
</filter>
|
||||
<linearGradient id="fs-male-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fs-female-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#E85D8A;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#D1477A;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g class="fs-tree-content" id="fs-tree-content"></g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const viewport = document.getElementById('fs-tree-viewport');
|
||||
|
||||
// Mouse wheel zoom
|
||||
viewport.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY * -0.001;
|
||||
const newScale = Math.max(0.1, Math.min(3, this.scale + delta));
|
||||
this.setZoom(newScale, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// Pan functionality
|
||||
let startX, startY;
|
||||
viewport.addEventListener('mousedown', (e) => {
|
||||
if (e.target.classList.contains('fs-person-card') || e.target.closest('.fs-person-card')) return;
|
||||
this.isDragging = true;
|
||||
startX = e.clientX - this.translateX;
|
||||
startY = e.clientY - this.translateY;
|
||||
viewport.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!this.isDragging) return;
|
||||
this.translateX = e.clientX - startX;
|
||||
this.translateY = e.clientY - startY;
|
||||
this.updateTransform();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
this.isDragging = false;
|
||||
viewport.style.cursor = 'grab';
|
||||
});
|
||||
}
|
||||
|
||||
initializeWithFirstPerson() {
|
||||
const firstPersonId = Object.keys(this.familyData)[0];
|
||||
if (firstPersonId) {
|
||||
this.loadPerson(firstPersonId);
|
||||
}
|
||||
}
|
||||
|
||||
loadPerson(personId) {
|
||||
this.currentPersonId = personId;
|
||||
this.treeData = this.buildTreeData(personId);
|
||||
this.renderTree();
|
||||
this.updateBreadcrumb();
|
||||
this.centerTree();
|
||||
}
|
||||
|
||||
buildTreeData(rootId) {
|
||||
const tree = {
|
||||
person: this.familyData[rootId],
|
||||
id: rootId,
|
||||
ancestors: this.buildAncestors(rootId, 3),
|
||||
descendants: this.buildDescendants(rootId, 2),
|
||||
siblings: this.buildSiblings(rootId),
|
||||
spouse: this.findSpouse(rootId)
|
||||
};
|
||||
return tree;
|
||||
}
|
||||
|
||||
buildAncestors(personId, generations) {
|
||||
if (generations <= 0) return null;
|
||||
|
||||
const person = this.familyData[personId];
|
||||
if (!person || !person.parents || person.parents.length === 0) return null;
|
||||
|
||||
const ancestors = {
|
||||
generation: generations,
|
||||
parents: []
|
||||
};
|
||||
|
||||
person.parents.forEach(parentId => {
|
||||
if (this.familyData[parentId]) {
|
||||
const parentData = {
|
||||
person: this.familyData[parentId],
|
||||
id: parentId,
|
||||
ancestors: this.buildAncestors(parentId, generations - 1),
|
||||
spouse: this.findSpouse(parentId)
|
||||
};
|
||||
ancestors.parents.push(parentData);
|
||||
}
|
||||
});
|
||||
|
||||
return ancestors.parents.length > 0 ? ancestors : null;
|
||||
}
|
||||
|
||||
buildDescendants(personId, generations) {
|
||||
if (generations <= 0) return null;
|
||||
|
||||
const person = this.familyData[personId];
|
||||
if (!person || !person.children || person.children.length === 0) return null;
|
||||
|
||||
const descendants = [];
|
||||
person.children.forEach(childId => {
|
||||
if (this.familyData[childId]) {
|
||||
const childData = {
|
||||
person: this.familyData[childId],
|
||||
id: childId,
|
||||
descendants: this.buildDescendants(childId, generations - 1),
|
||||
spouse: this.findSpouse(childId)
|
||||
};
|
||||
descendants.push(childData);
|
||||
}
|
||||
});
|
||||
|
||||
return descendants.length > 0 ? descendants : null;
|
||||
}
|
||||
|
||||
buildSiblings(personId) {
|
||||
const person = this.familyData[personId];
|
||||
if (!person || !person.parents || person.parents.length === 0) return [];
|
||||
|
||||
const siblings = [];
|
||||
const parentId = person.parents[0];
|
||||
const parent = this.familyData[parentId];
|
||||
|
||||
if (parent && parent.children) {
|
||||
parent.children.forEach(siblingId => {
|
||||
if (siblingId !== personId && this.familyData[siblingId]) {
|
||||
siblings.push({
|
||||
person: this.familyData[siblingId],
|
||||
id: siblingId,
|
||||
spouse: this.findSpouse(siblingId)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return siblings;
|
||||
}
|
||||
|
||||
findSpouse(personId) {
|
||||
const person = this.familyData[personId];
|
||||
if (!person || !person.spouse) return null;
|
||||
|
||||
const spouseId = Object.keys(this.familyData).find(id =>
|
||||
this.familyData[id].name === person.spouse
|
||||
);
|
||||
|
||||
return spouseId ? {
|
||||
person: this.familyData[spouseId],
|
||||
id: spouseId
|
||||
} : null;
|
||||
}
|
||||
|
||||
renderTree() {
|
||||
const content = document.getElementById('fs-tree-content');
|
||||
content.innerHTML = '';
|
||||
|
||||
const centerX = 0;
|
||||
const centerY = 0;
|
||||
|
||||
// Render main person at center
|
||||
this.renderPersonCard(content, this.treeData, centerX, centerY, 'main');
|
||||
|
||||
// Render spouse next to main person
|
||||
if (this.treeData.spouse) {
|
||||
this.renderPersonCard(content, this.treeData.spouse, centerX + this.cardWidth + 20, centerY, 'spouse');
|
||||
this.renderMarriageLine(content, centerX, centerY, centerX + this.cardWidth + 20, centerY);
|
||||
}
|
||||
|
||||
// Render ancestors (parents, grandparents, etc.)
|
||||
if (this.treeData.ancestors) {
|
||||
this.renderAncestors(content, this.treeData.ancestors, centerX, centerY - this.generationSpacing);
|
||||
}
|
||||
|
||||
// Render descendants (children, grandchildren)
|
||||
if (this.treeData.descendants) {
|
||||
this.renderDescendants(content, this.treeData.descendants, centerX, centerY + this.generationSpacing);
|
||||
}
|
||||
|
||||
// Render siblings
|
||||
if (this.treeData.siblings && this.treeData.siblings.length > 0) {
|
||||
this.renderSiblings(content, this.treeData.siblings, centerX, centerY);
|
||||
}
|
||||
}
|
||||
|
||||
renderPersonCard(container, personData, x, y, type = 'normal') {
|
||||
const person = personData.person;
|
||||
const gender = this.determineGender(person);
|
||||
|
||||
// Create card group
|
||||
const cardGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
cardGroup.setAttribute('class', `fs-person-card fs-card-${type} fs-gender-${gender}`);
|
||||
cardGroup.setAttribute('transform', `translate(${x}, ${y})`);
|
||||
cardGroup.style.cursor = 'pointer';
|
||||
|
||||
// Card background
|
||||
const cardBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
cardBg.setAttribute('width', this.cardWidth);
|
||||
cardBg.setAttribute('height', this.cardHeight);
|
||||
cardBg.setAttribute('rx', '8');
|
||||
cardBg.setAttribute('ry', '8');
|
||||
cardBg.setAttribute('fill', gender === 'female' ? 'url(#fs-female-gradient)' : 'url(#fs-male-gradient)');
|
||||
cardBg.setAttribute('stroke', type === 'main' ? '#FFD700' : 'rgba(255,255,255,0.3)');
|
||||
cardBg.setAttribute('stroke-width', type === 'main' ? '3' : '1');
|
||||
cardBg.setAttribute('filter', 'url(#fs-shadow)');
|
||||
|
||||
// Profile circle
|
||||
const profileCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
profileCircle.setAttribute('cx', '30');
|
||||
profileCircle.setAttribute('cy', '30');
|
||||
profileCircle.setAttribute('r', '20');
|
||||
profileCircle.setAttribute('fill', 'rgba(255,255,255,0.2)');
|
||||
profileCircle.setAttribute('stroke', 'rgba(255,255,255,0.5)');
|
||||
profileCircle.setAttribute('stroke-width', '2');
|
||||
|
||||
// Profile icon
|
||||
const profileIcon = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
profileIcon.setAttribute('x', '30');
|
||||
profileIcon.setAttribute('y', '37');
|
||||
profileIcon.setAttribute('text-anchor', 'middle');
|
||||
profileIcon.setAttribute('fill', 'white');
|
||||
profileIcon.setAttribute('font-family', 'FontAwesome');
|
||||
profileIcon.setAttribute('font-size', '16');
|
||||
profileIcon.textContent = gender === 'female' ? '\uf182' : '\uf183';
|
||||
|
||||
// Name text
|
||||
const nameText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
nameText.setAttribute('x', '65');
|
||||
nameText.setAttribute('y', '25');
|
||||
nameText.setAttribute('fill', 'white');
|
||||
nameText.setAttribute('font-family', 'Arial, sans-serif');
|
||||
nameText.setAttribute('font-size', '14');
|
||||
nameText.setAttribute('font-weight', 'bold');
|
||||
nameText.textContent = this.truncateText(person.name, 18);
|
||||
|
||||
// Title text
|
||||
const titleText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
titleText.setAttribute('x', '65');
|
||||
titleText.setAttribute('y', '42');
|
||||
titleText.setAttribute('fill', 'rgba(255,255,255,0.8)');
|
||||
titleText.setAttribute('font-family', 'Arial, sans-serif');
|
||||
titleText.setAttribute('font-size', '11');
|
||||
titleText.textContent = this.truncateText(person.title || 'Biblical Figure', 20);
|
||||
|
||||
// Dates text
|
||||
const datesText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
datesText.setAttribute('x', '10');
|
||||
datesText.setAttribute('y', '75');
|
||||
datesText.setAttribute('fill', 'rgba(255,255,255,0.7)');
|
||||
datesText.setAttribute('font-family', 'Arial, sans-serif');
|
||||
datesText.setAttribute('font-size', '10');
|
||||
const birthYear = person.birth_year && person.birth_year !== 'Unknown' ? person.birth_year : '?';
|
||||
const deathYear = person.death_year && person.death_year !== 'Unknown' ? person.death_year : '?';
|
||||
datesText.textContent = `${birthYear} - ${deathYear}`;
|
||||
|
||||
// Expand button
|
||||
const expandBtn = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
expandBtn.setAttribute('cx', this.cardWidth - 20);
|
||||
expandBtn.setAttribute('cy', '20');
|
||||
expandBtn.setAttribute('r', '12');
|
||||
expandBtn.setAttribute('fill', 'rgba(255,255,255,0.2)');
|
||||
expandBtn.setAttribute('stroke', 'rgba(255,255,255,0.5)');
|
||||
expandBtn.setAttribute('stroke-width', '1');
|
||||
expandBtn.style.cursor = 'pointer';
|
||||
|
||||
const expandIcon = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
expandIcon.setAttribute('x', this.cardWidth - 20);
|
||||
expandIcon.setAttribute('y', '25');
|
||||
expandIcon.setAttribute('text-anchor', 'middle');
|
||||
expandIcon.setAttribute('fill', 'white');
|
||||
expandIcon.setAttribute('font-family', 'FontAwesome');
|
||||
expandIcon.setAttribute('font-size', '10');
|
||||
expandIcon.textContent = '\uf065';
|
||||
|
||||
// Add all elements to card
|
||||
cardGroup.appendChild(cardBg);
|
||||
cardGroup.appendChild(profileCircle);
|
||||
cardGroup.appendChild(profileIcon);
|
||||
cardGroup.appendChild(nameText);
|
||||
cardGroup.appendChild(titleText);
|
||||
cardGroup.appendChild(datesText);
|
||||
cardGroup.appendChild(expandBtn);
|
||||
cardGroup.appendChild(expandIcon);
|
||||
|
||||
// Add click handler
|
||||
cardGroup.addEventListener('click', () => {
|
||||
this.selectPerson(personData.id);
|
||||
});
|
||||
|
||||
// Add hover effects
|
||||
cardGroup.addEventListener('mouseenter', () => {
|
||||
cardBg.setAttribute('stroke-width', '2');
|
||||
cardGroup.style.transform = `translate(${x}px, ${y}px) scale(1.02)`;
|
||||
});
|
||||
|
||||
cardGroup.addEventListener('mouseleave', () => {
|
||||
if (type !== 'main') cardBg.setAttribute('stroke-width', '1');
|
||||
cardGroup.style.transform = `translate(${x}px, ${y}px) scale(1)`;
|
||||
});
|
||||
|
||||
container.appendChild(cardGroup);
|
||||
return cardGroup;
|
||||
}
|
||||
|
||||
renderAncestors(container, ancestors, centerX, startY) {
|
||||
if (!ancestors || !ancestors.parents) return;
|
||||
|
||||
const parentSpacing = this.cardWidth + 40;
|
||||
const startX = centerX - (ancestors.parents.length - 1) * parentSpacing / 2;
|
||||
|
||||
ancestors.parents.forEach((parent, index) => {
|
||||
const x = startX + index * parentSpacing;
|
||||
const y = startY;
|
||||
|
||||
// Render parent card
|
||||
this.renderPersonCard(container, parent, x, y, 'ancestor');
|
||||
|
||||
// Render spouse if exists
|
||||
if (parent.spouse) {
|
||||
const spouseX = x + this.cardWidth + 20;
|
||||
this.renderPersonCard(container, parent.spouse, spouseX, y, 'ancestor');
|
||||
this.renderMarriageLine(container, x, y, spouseX, y);
|
||||
}
|
||||
|
||||
// Draw connection line to main person
|
||||
this.renderConnectionLine(container,
|
||||
x + this.cardWidth / 2, y + this.cardHeight,
|
||||
centerX + this.cardWidth / 2, startY + this.generationSpacing
|
||||
);
|
||||
|
||||
// Recursively render grandparents
|
||||
if (parent.ancestors) {
|
||||
this.renderAncestors(container, parent.ancestors, x, y - this.generationSpacing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderDescendants(container, descendants, centerX, startY) {
|
||||
if (!descendants || descendants.length === 0) return;
|
||||
|
||||
const childSpacing = this.cardWidth + 30;
|
||||
const startX = centerX - (descendants.length - 1) * childSpacing / 2;
|
||||
|
||||
descendants.forEach((child, index) => {
|
||||
const x = startX + index * childSpacing;
|
||||
const y = startY;
|
||||
|
||||
// Render child card
|
||||
this.renderPersonCard(container, child, x, y, 'descendant');
|
||||
|
||||
// Render spouse if exists
|
||||
if (child.spouse) {
|
||||
const spouseX = x + this.cardWidth + 20;
|
||||
this.renderPersonCard(container, child.spouse, spouseX, y, 'descendant');
|
||||
this.renderMarriageLine(container, x, y, spouseX, y);
|
||||
}
|
||||
|
||||
// Draw connection line to main person
|
||||
this.renderConnectionLine(container,
|
||||
centerX + this.cardWidth / 2, startY - this.generationSpacing,
|
||||
x + this.cardWidth / 2, y
|
||||
);
|
||||
|
||||
// Recursively render grandchildren
|
||||
if (child.descendants) {
|
||||
this.renderDescendants(container, child.descendants, x, y + this.generationSpacing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderSiblings(container, siblings, centerX, centerY) {
|
||||
if (!siblings || siblings.length === 0) return;
|
||||
|
||||
const siblingSpacing = this.cardWidth + 30;
|
||||
const startX = centerX - siblingSpacing * Math.ceil(siblings.length / 2);
|
||||
|
||||
siblings.forEach((sibling, index) => {
|
||||
const x = startX + index * siblingSpacing - this.cardWidth;
|
||||
const y = centerY + this.cardHeight + 40;
|
||||
|
||||
this.renderPersonCard(container, sibling, x, y, 'sibling');
|
||||
|
||||
if (sibling.spouse) {
|
||||
const spouseX = x + this.cardWidth + 20;
|
||||
this.renderPersonCard(container, sibling.spouse, spouseX, y, 'sibling');
|
||||
this.renderMarriageLine(container, x, y, spouseX, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderConnectionLine(container, x1, y1, x2, y2) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
line.setAttribute('d', `M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`);
|
||||
line.setAttribute('stroke', '#c0c0c0');
|
||||
line.setAttribute('stroke-width', '2');
|
||||
line.setAttribute('fill', 'none');
|
||||
line.setAttribute('stroke-linecap', 'round');
|
||||
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
renderMarriageLine(container, x1, y1, x2, y2) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x1 + this.cardWidth);
|
||||
line.setAttribute('y1', y1 + this.cardHeight / 2);
|
||||
line.setAttribute('x2', x2);
|
||||
line.setAttribute('y2', y2 + this.cardHeight / 2);
|
||||
line.setAttribute('stroke', '#FFD700');
|
||||
line.setAttribute('stroke-width', '3');
|
||||
line.setAttribute('stroke-linecap', 'round');
|
||||
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
selectPerson(personId) {
|
||||
if (personId === this.currentPersonId) return;
|
||||
|
||||
// Smooth transition effect
|
||||
const content = document.getElementById('fs-tree-content');
|
||||
content.style.opacity = '0.3';
|
||||
content.style.transition = 'opacity 0.3s ease';
|
||||
|
||||
setTimeout(() => {
|
||||
this.loadPerson(personId);
|
||||
content.style.opacity = '1';
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
determineGender(person) {
|
||||
const name = person.name.toLowerCase();
|
||||
const femaleNames = ['eve', 'sarah', 'rebekah', 'rachel', 'leah', 'mary', 'elizabeth', 'ruth', 'naomi'];
|
||||
return femaleNames.some(fname => name.includes(fname)) ? 'female' : 'male';
|
||||
}
|
||||
|
||||
truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
|
||||
}
|
||||
|
||||
updateBreadcrumb() {
|
||||
const breadcrumb = document.getElementById('fs-breadcrumb-text');
|
||||
const person = this.familyData[this.currentPersonId];
|
||||
breadcrumb.textContent = person ? person.name : 'Biblical Family Tree';
|
||||
}
|
||||
|
||||
// Zoom and pan methods
|
||||
zoomIn() {
|
||||
this.setZoom(Math.min(3, this.scale * 1.2));
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
this.setZoom(Math.max(0.1, this.scale / 1.2));
|
||||
}
|
||||
|
||||
setZoom(newScale, centerX = null, centerY = null) {
|
||||
const viewport = document.getElementById('fs-tree-viewport');
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
|
||||
const cx = centerX || rect.width / 2;
|
||||
const cy = centerY || rect.height / 2;
|
||||
|
||||
this.scale = newScale;
|
||||
this.updateTransform();
|
||||
}
|
||||
|
||||
centerTree() {
|
||||
this.translateX = 0;
|
||||
this.translateY = 0;
|
||||
this.scale = 1;
|
||||
this.updateTransform();
|
||||
}
|
||||
|
||||
updateTransform() {
|
||||
const content = document.getElementById('fs-tree-content');
|
||||
content.setAttribute('transform',
|
||||
`translate(${this.translateX}, ${this.translateY}) scale(${this.scale})`
|
||||
);
|
||||
}
|
||||
|
||||
switchView(viewType) {
|
||||
// Remove active state from all buttons
|
||||
document.querySelectorAll('.fs-btn-compact').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active state to clicked button
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Adjust tree focus based on view type
|
||||
switch(viewType) {
|
||||
case 'ancestors':
|
||||
this.focusOnAncestors();
|
||||
break;
|
||||
case 'family':
|
||||
this.centerTree();
|
||||
break;
|
||||
case 'descendants':
|
||||
this.focusOnDescendants();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
focusOnAncestors() {
|
||||
this.translateY = 200;
|
||||
this.updateTransform();
|
||||
}
|
||||
|
||||
focusOnDescendants() {
|
||||
this.translateY = -200;
|
||||
this.updateTransform();
|
||||
}
|
||||
}
|
||||
|
||||
// CSS for FamilySearch-style tree (to be added to the page)
|
||||
const familySearchCSS = `
|
||||
<style>
|
||||
.fs-tree-wrapper {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.fs-tree-controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fs-control-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fs-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fs-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #007bff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.fs-btn.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.fs-btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fs-btn-compact {
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.fs-breadcrumb {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fs-tree-viewport {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: grab;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-tree-viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.fs-tree-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fs-tree-content {
|
||||
transform-origin: center;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fs-person-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fs-person-card:hover {
|
||||
transform: scale(1.02) !important;
|
||||
}
|
||||
|
||||
.fs-card-main .fs-person-card rect {
|
||||
stroke: #FFD700;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fs-person-card {
|
||||
animation: fadeInUp 0.3s ease forwards;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Auto-inject CSS
|
||||
document.head.insertAdjacentHTML('beforeend', familySearchCSS);
|
||||
|
||||
// Global instance for easy access
|
||||
let fsTree = null;
|
||||
|
||||
// Initialize function
|
||||
function initializeFamilySearchTree(containerId, familyData) {
|
||||
fsTree = new FamilySearchStyleTree(containerId, familyData);
|
||||
return fsTree;
|
||||
}
|
||||
|
||||
// Export for use
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { FamilySearchStyleTree, initializeFamilySearchTree };
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block description %}Explore biblical genealogies with an interactive person index and family tree viewer. Browse from Adam to the patriarchs with detailed family relationships.{% endblock %}
|
||||
{% block keywords %}biblical family tree, biblical genealogy, Adam and Eve, patriarchs, biblical lineage, Old Testament families, KJV genealogy{% endblock %}
|
||||
{% block og_title %}Biblical Family Tree Explorer - KJV Study{% endblock %}
|
||||
{% block container_class %}container-fluid{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!-- D3.js Library -->
|
||||
@@ -17,6 +18,11 @@
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
.container-fluid {
|
||||
max-width: none !important;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.family-explorer-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -47,117 +53,11 @@
|
||||
}
|
||||
|
||||
.explorer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 2rem;
|
||||
display: block;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.person-index {
|
||||
background: var(--card-background);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
height: fit-content;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.index-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.index-header h2 {
|
||||
color: var(--primary-color);
|
||||
font-family: "Crimson Text", serif;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-box:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(75, 46, 131, 0.1);
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.person-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.person-item {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.person-item:hover {
|
||||
background: var(--hover-background);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.person-item.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.person-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.person-title {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.person-generation {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.family-viewer {
|
||||
background: var(--card-background);
|
||||
@@ -407,14 +307,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.d3-tree-container {
|
||||
.familysearch-tree-container {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
height: 700px;
|
||||
background: var(--background-color);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.d3-tree-svg {
|
||||
@@ -626,6 +527,8 @@
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 4rem 2rem;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.welcome-message h3 {
|
||||
@@ -804,40 +707,19 @@
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.explorer-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.person-index {
|
||||
max-height: 40vh;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.family-viewer {
|
||||
order: 1;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.family-explorer-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
|
||||
.explorer-layout {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.person-index {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
|
||||
.family-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -956,53 +838,28 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-persons">0</div>
|
||||
<div class="stat-label">Total Persons</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-families">0</div>
|
||||
<div class="stat-label">Family Groups</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-generations">0</div>
|
||||
<div class="stat-label">Generations</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="selected-generation">-</div>
|
||||
<div class="stat-label">Selected Person</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explorer-layout">
|
||||
<div class="person-index">
|
||||
<div class="index-header">
|
||||
<h2>📜 Person Index</h2>
|
||||
<input
|
||||
type="text"
|
||||
class="search-box"
|
||||
id="person-search"
|
||||
placeholder="Search by name..."
|
||||
onkeyup="filterPersons()"
|
||||
>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" onclick="filterByLineage('all')">All</button>
|
||||
<button class="filter-btn" onclick="filterByLineage('seth')">Seth's Line</button>
|
||||
<button class="filter-btn" onclick="filterByLineage('cain')">Cain's Line</button>
|
||||
<button class="filter-btn" onclick="filterByLineage('noah')">Noah's Sons</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="person-list" id="person-list">
|
||||
<!-- Persons will be populated by JavaScript -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="family-viewer">
|
||||
|
||||
|
||||
<div class="welcome-message" id="welcome-message">
|
||||
<h3>👈 Select a Person</h3>
|
||||
<p>Choose someone from the person index to explore their family relationships and see relevant scripture passages.</p>
|
||||
<h3>🌳 Biblical Family Tree Explorer</h3>
|
||||
<p>Click on any person in the family tree to explore their relationships and see relevant scripture passages.</p>
|
||||
<p>The biblical genealogies contain {{ family_tree_data|length }} individuals spanning many generations from Adam to the patriarchs of Israel.</p>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="findAndSelectPerson('adam')" style="padding: 10px 20px; background: var(--primary-color); color: white; border: none; border-radius: 5px; cursor: pointer; margin: 5px;">
|
||||
Start with Adam
|
||||
</button>
|
||||
<button onclick="findAndSelectPerson('noah')" style="padding: 10px 20px; background: var(--secondary-color); color: white; border: none; border-radius: 5px; cursor: pointer; margin: 5px;">
|
||||
Start with Noah
|
||||
</button>
|
||||
<button onclick="findAndSelectPerson('abraham')" style="padding: 10px 20px; background: var(--accent-color); color: white; border: none; border-radius: 5px; cursor: pointer; margin: 5px;">
|
||||
Start with Abraham
|
||||
</button>
|
||||
<button onclick="showAvailablePeople()" style="padding: 10px 20px; background: #6c757d; color: white; border: none; border-radius: 5px; cursor: pointer; margin: 5px;">
|
||||
Browse All People
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-toggle" id="view-toggle" style="display: flex;">
|
||||
@@ -1011,7 +868,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Layout Selector -->
|
||||
<div class="layout-selector" id="layout-selector" style="display: none; justify-content: center; gap: 10px; margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px;">
|
||||
<div class="layout-selector" id="layout-selector" style="display: none; justify-content: center; gap: 10px; margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px; width: 100%; max-width: none;">
|
||||
<button class="layout-btn active" onclick="switchLayout('hierarchical')" title="Traditional family tree">
|
||||
<i class="fas fa-sitemap"></i> Hierarchical
|
||||
</button>
|
||||
@@ -1027,7 +884,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Search Panel -->
|
||||
<div class="search-panel" id="search-panel" style="display: none; background: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||
<div class="search-panel" id="search-panel" style="display: none; background: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0; width: 100%; max-width: none;">
|
||||
<div class="search-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h4><i class="fas fa-search"></i> Advanced Search</h4>
|
||||
<button onclick="toggleSearch()" class="close-btn" style="background: none; border: none; font-size: 18px; cursor: pointer;">×</button>
|
||||
@@ -1054,7 +911,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Analytics Panel -->
|
||||
<div class="analytics-panel" id="analytics-panel" style="display: none; background: white; border: 1px solid #ddd; border-radius: 8px; margin: 20px 0;">
|
||||
<div class="analytics-panel" id="analytics-panel" style="display: none; background: white; border: 1px solid #ddd; border-radius: 8px; margin: 20px 0; width: 100%; max-width: none;">
|
||||
<div class="analytics-header" style="background: #007bff; color: white; padding: 15px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h4><i class="fas fa-chart-bar"></i> Family Tree Analytics</h4>
|
||||
<button onclick="toggleAnalytics()" class="close-btn" style="background: none; border: none; color: white; font-size: 18px; cursor: pointer;">×</button>
|
||||
@@ -1084,40 +941,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-view" id="tree-view">
|
||||
<div class="d3-tree-container">
|
||||
<div class="tree-controls">
|
||||
<button onclick="centerTree()">Center</button>
|
||||
<button onclick="expandAll()">Expand All</button>
|
||||
<button onclick="collapseAll()">Collapse All</button>
|
||||
<button onclick="toggleSearch()" style="background: #28a745; color: white; margin-left: 10px;">
|
||||
<i class="fas fa-search"></i> Search
|
||||
</button>
|
||||
<button onclick="toggleAnalytics()" style="background: #fd7e14; color: white;">
|
||||
<i class="fas fa-chart-bar"></i> Analytics
|
||||
</button>
|
||||
<button onclick="exportTree()" style="background: #6c757d; color: white;">
|
||||
<i class="fas fa-download"></i> Export
|
||||
</button>
|
||||
</div>
|
||||
<svg class="d3-tree-svg" id="tree-svg"></svg>
|
||||
|
||||
<div class="gender-legend">
|
||||
<div class="legend-title">Gender</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-circle male"></div>
|
||||
<span>Male</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-circle female"></div>
|
||||
<span>Female</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-circle current"></div>
|
||||
<span>Current Person</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tree-view" id="tree-view" style="display: block;">
|
||||
<div id="familysearch-tree-container" class="familysearch-tree-container">
|
||||
<!-- FamilySearch-style tree will be rendered here -->
|
||||
</div>
|
||||
|
||||
<!-- Legacy controls for search and analytics -->
|
||||
<div class="legacy-controls" style="margin-top: 20px; text-align: center;">
|
||||
<button onclick="toggleSearch()" style="background: #28a745; color: white; margin: 5px; padding: 10px 15px; border: none; border-radius: 5px; cursor: pointer;">
|
||||
<i class="fas fa-search"></i> Search
|
||||
</button>
|
||||
<button onclick="toggleAnalytics()" style="background: #fd7e14; color: white; margin: 5px; padding: 10px 15px; border: none; border-radius: 5px; cursor: pointer;">
|
||||
<i class="fas fa-chart-bar"></i> Analytics
|
||||
</button>
|
||||
<button onclick="exportTree()" style="background: #6c757d; color: white; margin: 5px; padding: 10px 15px; border: none; border-radius: 5px; cursor: pointer;">
|
||||
<i class="fas fa-download"></i> Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comprehensive-person-view" id="comprehensive-person-view" style="display: block;">
|
||||
<div class="person-header">
|
||||
@@ -1222,15 +1063,61 @@
|
||||
// Update statistics
|
||||
updateStatistics();
|
||||
|
||||
// Populate person list
|
||||
populatePersonList();
|
||||
|
||||
// Debug: Log available people to find Adam
|
||||
console.log('Available people in family tree:');
|
||||
Object.keys(familyData).slice(0, 20).forEach(id => {
|
||||
console.log(`- ${familyData[id].name} (ID: ${id})`);
|
||||
});
|
||||
|
||||
// Select Adam by default if available
|
||||
const adamId = Object.keys(familyData).find(id =>
|
||||
let adamId = Object.keys(familyData).find(id =>
|
||||
familyData[id].name.toLowerCase() === 'adam'
|
||||
);
|
||||
|
||||
// If exact match not found, try partial match
|
||||
if (!adamId) {
|
||||
adamId = Object.keys(familyData).find(id =>
|
||||
familyData[id].name.toLowerCase().includes('adam')
|
||||
);
|
||||
console.log('Adam found by partial match:', adamId ? familyData[adamId].name : 'None');
|
||||
}
|
||||
|
||||
// If still not found, try biblical figures
|
||||
if (!adamId) {
|
||||
const biblicalFigures = ['noah', 'abraham', 'isaac', 'jacob', 'moses', 'david'];
|
||||
for (const figure of biblicalFigures) {
|
||||
adamId = Object.keys(familyData).find(id =>
|
||||
familyData[id].name.toLowerCase().includes(figure)
|
||||
);
|
||||
if (adamId) {
|
||||
console.log(`Selected ${figure} instead of Adam:`, familyData[adamId].name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, get the first person alphabetically
|
||||
if (!adamId) {
|
||||
const sortedPeople = Object.keys(familyData).sort((a, b) =>
|
||||
familyData[a].name.localeCompare(familyData[b].name)
|
||||
);
|
||||
adamId = sortedPeople[0];
|
||||
console.log('Selected first alphabetical person:', familyData[adamId].name);
|
||||
}
|
||||
|
||||
if (adamId) {
|
||||
selectPerson(adamId);
|
||||
currentPersonId = adamId;
|
||||
console.log('Final selected person:', familyData[adamId].name);
|
||||
}
|
||||
|
||||
// Initialize FamilySearch-style tree with Adam selected
|
||||
initializeFamilySearchStyleTree();
|
||||
|
||||
// Hide welcome message and show tree view by default
|
||||
if (currentPersonId) {
|
||||
document.getElementById('welcome-message').style.display = 'none';
|
||||
document.getElementById('view-toggle').style.display = 'flex';
|
||||
document.getElementById('layout-selector').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,53 +1132,7 @@
|
||||
document.getElementById('total-generations').textContent = '12+';
|
||||
}
|
||||
|
||||
function populatePersonList() {
|
||||
const personList = document.getElementById('person-list');
|
||||
personList.innerHTML = '';
|
||||
|
||||
let filteredPersons = allPersons;
|
||||
|
||||
// Apply search filter
|
||||
const searchTerm = document.getElementById('person-search').value.toLowerCase();
|
||||
if (searchTerm) {
|
||||
filteredPersons = filteredPersons.filter(person =>
|
||||
person.name.toLowerCase().includes(searchTerm) ||
|
||||
(person.title && person.title.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply lineage filter
|
||||
if (currentFilter !== 'all') {
|
||||
filteredPersons = filteredPersons.filter(person => {
|
||||
const lineage = determineLineage(person);
|
||||
return lineage === currentFilter;
|
||||
});
|
||||
}
|
||||
|
||||
filteredPersons.forEach(person => {
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = 'person-item';
|
||||
if (currentPersonId === person.id) {
|
||||
listItem.classList.add('active');
|
||||
}
|
||||
|
||||
const lineage = determineLineage(person);
|
||||
const generationInfo = getGenerationInfo(person);
|
||||
|
||||
listItem.innerHTML = `
|
||||
<div class="person-name">${person.name}</div>
|
||||
<div class="person-title">${person.title || 'Biblical Figure'}</div>
|
||||
<div class="person-generation">${lineage} • ${generationInfo}</div>
|
||||
`;
|
||||
|
||||
listItem.onclick = () => selectPerson(person.id);
|
||||
personList.appendChild(listItem);
|
||||
});
|
||||
|
||||
if (filteredPersons.length === 0) {
|
||||
personList.innerHTML = '<li style="padding: 1rem; text-align: center; color: var(--text-muted);">No persons found</li>';
|
||||
}
|
||||
}
|
||||
|
||||
function determineLineage(person) {
|
||||
// Simple lineage determination based on name patterns and known relationships
|
||||
@@ -1341,6 +1182,16 @@
|
||||
|
||||
if (!person) return;
|
||||
|
||||
// Hide welcome message and show tree view controls
|
||||
document.getElementById('welcome-message').style.display = 'none';
|
||||
document.getElementById('view-toggle').style.display = 'flex';
|
||||
document.getElementById('layout-selector').style.display = 'flex';
|
||||
|
||||
// Update FamilySearch tree if available
|
||||
if (window.fsTreeInstance) {
|
||||
window.fsTreeInstance.loadPerson(personId);
|
||||
}
|
||||
|
||||
// Update active state in person list
|
||||
document.querySelectorAll('.person-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
@@ -1917,21 +1768,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function filterPersons() {
|
||||
populatePersonList();
|
||||
}
|
||||
|
||||
function filterByLineage(lineage) {
|
||||
currentFilter = lineage;
|
||||
|
||||
// Update active filter button
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
populatePersonList();
|
||||
}
|
||||
|
||||
function showDetailsView() {
|
||||
document.getElementById('person-details').classList.remove('details-view');
|
||||
@@ -1953,9 +1790,14 @@
|
||||
// Show layout selector
|
||||
document.getElementById('layout-selector').style.display = 'flex';
|
||||
|
||||
// Update D3 tree when switching to tree view
|
||||
if (currentPersonId) {
|
||||
updateD3Tree(familyData[currentPersonId], currentPersonId);
|
||||
// Initialize FamilySearch-style tree if not already done
|
||||
if (!window.fsTreeInstance) {
|
||||
initializeFamilySearchStyleTree();
|
||||
}
|
||||
|
||||
// Update tree when switching to tree view
|
||||
if (currentPersonId && window.fsTreeInstance) {
|
||||
window.fsTreeInstance.loadPerson(currentPersonId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2442,6 +2284,98 @@
|
||||
img.src = 'data:image/svg+xml;base64,' + btoa(svgData);
|
||||
}
|
||||
|
||||
function findAndSelectPerson(name) {
|
||||
// Try to find person by exact name match
|
||||
const personId = Object.keys(familyData).find(id =>
|
||||
familyData[id].name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
|
||||
if (personId) {
|
||||
if (window.fsTreeInstance) {
|
||||
window.fsTreeInstance.loadPerson(personId);
|
||||
}
|
||||
currentPersonId = personId;
|
||||
updateComprehensiveView(familyData[personId], personId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find person by partial name match
|
||||
const partialMatch = Object.keys(familyData).find(id =>
|
||||
familyData[id].name.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
|
||||
if (partialMatch) {
|
||||
if (window.fsTreeInstance) {
|
||||
window.fsTreeInstance.loadPerson(partialMatch);
|
||||
}
|
||||
currentPersonId = partialMatch;
|
||||
updateComprehensiveView(familyData[partialMatch], partialMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not found, show available people with similar names
|
||||
alert(`${name} not found in the family tree. Click "Browse All People" to see available persons.`);
|
||||
showAvailablePeople();
|
||||
}
|
||||
|
||||
function initializeFamilySearchStyleTree() {
|
||||
// Wait for the script to load
|
||||
setTimeout(() => {
|
||||
if (typeof FamilySearchStyleTree === 'undefined') {
|
||||
console.error('FamilySearchStyleTree not loaded, retrying...');
|
||||
setTimeout(initializeFamilySearchStyleTree, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
window.fsTreeInstance = new FamilySearchStyleTree('familysearch-tree-container', familyData);
|
||||
|
||||
// Set up callback for person selection
|
||||
const originalSelectPerson = window.fsTreeInstance.selectPerson;
|
||||
window.fsTreeInstance.selectPerson = function(personId) {
|
||||
currentPersonId = personId;
|
||||
updateComprehensiveView(familyData[personId], personId);
|
||||
originalSelectPerson.call(this, personId);
|
||||
};
|
||||
|
||||
// Load Adam by default if we have a currentPersonId
|
||||
if (currentPersonId) {
|
||||
window.fsTreeInstance.loadPerson(currentPersonId);
|
||||
updateComprehensiveView(familyData[currentPersonId], currentPersonId);
|
||||
|
||||
// Hide welcome message and show controls
|
||||
document.getElementById('welcome-message').style.display = 'none';
|
||||
document.getElementById('view-toggle').style.display = 'flex';
|
||||
document.getElementById('layout-selector').style.display = 'flex';
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function showAvailablePeople() {
|
||||
const people = Object.keys(familyData).map(id => ({
|
||||
id: id,
|
||||
name: familyData[id].name,
|
||||
title: familyData[id].title || 'Biblical Figure'
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let peopleList = 'Available people in the family tree:\n\n';
|
||||
people.slice(0, 20).forEach(person => {
|
||||
peopleList += `• ${person.name} (${person.title})\n`;
|
||||
});
|
||||
|
||||
if (people.length > 20) {
|
||||
peopleList += `\n... and ${people.length - 20} more people`;
|
||||
}
|
||||
|
||||
peopleList += '\n\nClick on any person in the tree or use the Search feature to explore!';
|
||||
|
||||
alert(peopleList);
|
||||
|
||||
// Auto-select the first person for demo
|
||||
if (people.length > 0) {
|
||||
selectPerson(people[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
let treeData = null;
|
||||
let svg = null;
|
||||
let g = null;
|
||||
@@ -2524,9 +2458,98 @@
|
||||
|
||||
function buildTreeData(rootPersonId, maxDepth = 3) {
|
||||
const visited = new Set();
|
||||
const allNodes = {};
|
||||
|
||||
function buildNode(personId, depth = 0) {
|
||||
if (!personId || visited.has(personId) || depth > maxDepth) return null;
|
||||
// Build a comprehensive family tree with parents, current person, and children
|
||||
function buildExpandedTree(rootId) {
|
||||
const rootPerson = familyData[rootId];
|
||||
if (!rootPerson) return null;
|
||||
|
||||
// Create the root node
|
||||
const rootNode = {
|
||||
id: rootId,
|
||||
name: rootPerson.name,
|
||||
data: rootPerson,
|
||||
gender: determineGender(rootPerson),
|
||||
children: [],
|
||||
spouse: null,
|
||||
isRoot: true
|
||||
};
|
||||
|
||||
// Add spouse to root
|
||||
if (rootPerson.spouse) {
|
||||
const spouseId = Object.keys(familyData).find(id => familyData[id].name === rootPerson.spouse);
|
||||
if (spouseId) {
|
||||
const spouseData = familyData[spouseId];
|
||||
rootNode.spouse = {
|
||||
id: spouseId,
|
||||
name: rootPerson.spouse,
|
||||
data: spouseData,
|
||||
gender: determineGender(spouseData)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add children to root
|
||||
if (rootPerson.children) {
|
||||
rootPerson.children.forEach(childId => {
|
||||
const childNode = buildNode(childId, 1, 'descendant');
|
||||
if (childNode) {
|
||||
rootNode.children.push(childNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add parents above root if they exist
|
||||
if (rootPerson.parents && rootPerson.parents.length > 0) {
|
||||
// Create a parent generation
|
||||
const parentNodes = [];
|
||||
rootPerson.parents.forEach(parentId => {
|
||||
const parentNode = buildNode(parentId, -1, 'ancestor');
|
||||
if (parentNode) {
|
||||
parentNodes.push(parentNode);
|
||||
}
|
||||
});
|
||||
|
||||
if (parentNodes.length > 0) {
|
||||
// Create a virtual parent container
|
||||
const parentContainer = {
|
||||
id: 'parents_of_' + rootId,
|
||||
name: 'Parents',
|
||||
isParentContainer: true,
|
||||
children: [rootNode],
|
||||
data: { name: 'Parents', title: 'Parent Generation' }
|
||||
};
|
||||
|
||||
// Add parent nodes as children of grandparent level
|
||||
if (parentNodes.length === 1) {
|
||||
// Single parent
|
||||
parentNodes[0].children = [rootNode];
|
||||
return parentNodes[0];
|
||||
} else {
|
||||
// Multiple parents - create grandparent level
|
||||
const grandparentContainer = {
|
||||
id: 'grandparents_of_' + rootId,
|
||||
name: 'Grandparents',
|
||||
isGrandparentContainer: true,
|
||||
children: parentNodes,
|
||||
data: { name: 'Grandparents', title: 'Grandparent Generation' }
|
||||
};
|
||||
|
||||
parentNodes.forEach(parent => {
|
||||
parent.children = [rootNode];
|
||||
});
|
||||
|
||||
return grandparentContainer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rootNode;
|
||||
}
|
||||
|
||||
function buildNode(personId, depth, direction) {
|
||||
if (!personId || visited.has(personId) || Math.abs(depth) > maxDepth) return null;
|
||||
|
||||
visited.add(personId);
|
||||
const person = familyData[personId];
|
||||
@@ -2538,7 +2561,8 @@
|
||||
data: person,
|
||||
gender: determineGender(person),
|
||||
children: [],
|
||||
spouse: null
|
||||
spouse: null,
|
||||
direction: direction // 'ancestor', 'descendant', or undefined for root
|
||||
};
|
||||
|
||||
// Add spouse
|
||||
@@ -2555,24 +2579,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Add children
|
||||
if (person.children && depth < maxDepth) {
|
||||
// Add children for descendants or if we're going up the tree
|
||||
if (direction === 'descendant' && person.children && depth < maxDepth) {
|
||||
person.children.forEach(childId => {
|
||||
const childNode = buildNode(childId, depth + 1);
|
||||
const childNode = buildNode(childId, depth + 1, 'descendant');
|
||||
if (childNode) {
|
||||
node.children.push(childNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add parents if we're at root and depth allows
|
||||
if (depth === 0 && person.parents) {
|
||||
// Add parents for ancestors or if we're going up the tree
|
||||
if (direction === 'ancestor' && person.parents && depth > -maxDepth) {
|
||||
person.parents.forEach(parentId => {
|
||||
const parentNode = buildNode(parentId, -1);
|
||||
const parentNode = buildNode(parentId, depth - 1, 'ancestor');
|
||||
if (parentNode) {
|
||||
// Parents are added as reverse children for upward tree
|
||||
parentNode.children = [node];
|
||||
return parentNode;
|
||||
node.children.push(parentNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2580,7 +2602,7 @@
|
||||
return node;
|
||||
}
|
||||
|
||||
return buildNode(rootPersonId);
|
||||
return buildExpandedTree(rootPersonId);
|
||||
}
|
||||
|
||||
function updateD3Tree(person, personId) {
|
||||
@@ -2694,5 +2716,13 @@
|
||||
updateD3Tree(familyData[currentPersonId], currentPersonId);
|
||||
}
|
||||
}
|
||||
// Load FamilySearch-style tree
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/familysearch-style-tree.js';
|
||||
script.onload = function() {
|
||||
console.log('FamilySearch-style tree loaded');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user