diff --git a/kjvstudy_org/static/js/familysearch-style-tree.js b/kjvstudy_org/static/js/familysearch-style-tree.js new file mode 100644 index 0000000..3bae62e --- /dev/null +++ b/kjvstudy_org/static/js/familysearch-style-tree.js @@ -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 = ` +
+
+
+ + + +
+
+ + + +
+
+
+ Biblical Family Tree +
+
+
+ +
+ + + + + + + + + + + + + + + + +
+
+ `; + } + + 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 = ` + +`; + +// 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 }; +} \ No newline at end of file diff --git a/kjvstudy_org/templates/family_tree.html b/kjvstudy_org/templates/family_tree.html index 9f3fe22..2f6ab65 100644 --- a/kjvstudy_org/templates/family_tree.html +++ b/kjvstudy_org/templates/family_tree.html @@ -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 %} @@ -17,6 +18,11 @@