From 7c92e5ce18852886072c7dbd4ac6f7c761e06d91 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Fri, 30 May 2025 19:46:13 -0400 Subject: [PATCH] Add family tree visualization features and analytics The changes introduce three new JavaScript files that add comprehensive family tree visualization and analysis capabilities: 1. Advanced tree layouts with multiple visualization modes (hierarchical, radial, force-directed, timeline, and circular pedigree) 2. Detailed analytics and statistics with interactive charts 3. Advanced search functionality with tree highlighting and navigation features The additions provide users with richer ways to explore and analyze biblical family relationships. --- .../static/js/advanced-tree-layouts.js | 542 +++++++++++ .../static/js/family-tree-analytics.js | 863 +++++++++++++++++ kjvstudy_org/static/js/family-tree-search.js | 916 ++++++++++++++++++ 3 files changed, 2321 insertions(+) create mode 100644 kjvstudy_org/static/js/advanced-tree-layouts.js create mode 100644 kjvstudy_org/static/js/family-tree-analytics.js create mode 100644 kjvstudy_org/static/js/family-tree-search.js diff --git a/kjvstudy_org/static/js/advanced-tree-layouts.js b/kjvstudy_org/static/js/advanced-tree-layouts.js new file mode 100644 index 0000000..223bf11 --- /dev/null +++ b/kjvstudy_org/static/js/advanced-tree-layouts.js @@ -0,0 +1,542 @@ +/** + * Advanced Tree Layout Options for KJV Study Family Tree + * Provides multiple visualization modes beyond the standard hierarchical tree + */ + +class AdvancedTreeLayouts { + constructor(svg, familyData) { + this.svg = svg; + this.familyData = familyData; + this.currentLayout = 'hierarchical'; + this.width = 800; + this.height = 600; + this.simulation = null; + this.zoom = null; + this.g = null; + + this.initializeZoom(); + } + + initializeZoom() { + this.zoom = d3.zoom() + .scaleExtent([0.1, 3]) + .on("zoom", (event) => { + if (this.g) { + this.g.attr("transform", event.transform); + } + }); + + this.svg.call(this.zoom); + this.g = this.svg.append("g"); + } + + updateDimensions() { + const rect = this.svg.node().getBoundingClientRect(); + this.width = rect.width; + this.height = rect.height; + } + + /** + * Radial Tree Layout - Shows generations in concentric circles + */ + renderRadialLayout(rootPersonId, maxGenerations = 4) { + this.currentLayout = 'radial'; + this.updateDimensions(); + + const treeData = this.buildRadialTreeData(rootPersonId, maxGenerations); + if (!treeData) return; + + this.g.selectAll("*").remove(); + + const centerX = this.width / 2; + const centerY = this.height / 2; + const maxRadius = Math.min(this.width, this.height) / 2 - 60; + + // Calculate positions for each generation + const generations = this.getGenerationLevels(treeData); + const radiusStep = maxRadius / Math.max(generations.length - 1, 1); + + // Position nodes in concentric circles + generations.forEach((generation, genIndex) => { + const radius = genIndex * radiusStep; + const angleStep = (2 * Math.PI) / Math.max(generation.length, 1); + + generation.forEach((node, nodeIndex) => { + const angle = nodeIndex * angleStep - Math.PI / 2; // Start from top + node.x = centerX + radius * Math.cos(angle); + node.y = centerY + radius * Math.sin(angle); + }); + }); + + this.drawRadialConnections(treeData); + this.drawNodes(treeData.nodes, rootPersonId); + + // Add generation labels + this.addGenerationLabels(generations, centerX, centerY, radiusStep); + } + + /** + * Force-Directed Layout - Dynamic positioning based on relationships + */ + renderForceDirectedLayout(rootPersonId, includeExtended = true) { + this.currentLayout = 'force-directed'; + this.updateDimensions(); + + const graphData = this.buildGraphData(rootPersonId, includeExtended); + if (!graphData.nodes.length) return; + + this.g.selectAll("*").remove(); + + // Create force simulation + this.simulation = d3.forceSimulation(graphData.nodes) + .force("link", d3.forceLink(graphData.links) + .id(d => d.id) + .distance(d => this.getLinkDistance(d)) + .strength(d => this.getLinkStrength(d))) + .force("charge", d3.forceManyBody() + .strength(-300) + .distanceMax(200)) + .force("center", d3.forceCenter(this.width / 2, this.height / 2)) + .force("collision", d3.forceCollide().radius(25)); + + // Draw links + const links = this.g.selectAll('.force-link') + .data(graphData.links) + .enter() + .append('line') + .attr('class', d => `force-link ${d.type}`) + .attr('stroke-width', d => d.type === 'marriage' ? 3 : 2) + .attr('stroke', d => this.getLinkColor(d.type)); + + // Draw nodes + const nodes = this.g.selectAll('.force-node') + .data(graphData.nodes) + .enter() + .append('g') + .attr('class', d => `force-node ${d.gender} ${d.id === rootPersonId ? 'current' : ''}`) + .call(d3.drag() + .on("start", (event, d) => this.dragStarted(event, d)) + .on("drag", (event, d) => this.dragged(event, d)) + .on("end", (event, d) => this.dragEnded(event, d))) + .on('click', (event, d) => { + this.selectPerson(d.id); + }); + + nodes.append('circle') + .attr('r', d => d.id === rootPersonId ? 12 : 8) + .attr('fill', d => this.getNodeColor(d, rootPersonId)); + + nodes.append('text') + .attr('dy', '.35em') + .attr('text-anchor', 'middle') + .style('font-size', '10px') + .style('font-weight', d => d.id === rootPersonId ? 'bold' : 'normal') + .text(d => this.truncateName(d.name, 12)); + + // Update positions on simulation tick + this.simulation.on("tick", () => { + links + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y); + + nodes.attr("transform", d => `translate(${d.x},${d.y})`); + }); + } + + /** + * Timeline Tree Layout - Shows family relationships across biblical timeline + */ + renderTimelineLayout(rootPersonId) { + this.currentLayout = 'timeline'; + this.updateDimensions(); + + const timelineData = this.buildTimelineData(rootPersonId); + if (!timelineData.length) return; + + this.g.selectAll("*").remove(); + + // Sort by estimated birth year + timelineData.sort((a, b) => (a.estimatedYear || 0) - (b.estimatedYear || 0)); + + const padding = 50; + const timelineWidth = this.width - 2 * padding; + const timelineHeight = this.height - 2 * padding; + + // Create time scale + const years = timelineData.map(d => d.estimatedYear || 0).filter(y => y > 0); + const minYear = Math.min(...years) || 4000; + const maxYear = Math.max(...years) || 1; + + const xScale = d3.scaleLinear() + .domain([minYear, maxYear]) + .range([padding, this.width - padding]); + + // Group by generation for y-positioning + const generations = this.groupByGeneration(timelineData); + const yScale = d3.scaleBand() + .domain(Object.keys(generations)) + .range([padding, this.height - padding]) + .paddingInner(0.2); + + // Draw timeline axis + this.drawTimelineAxis(xScale, minYear, maxYear); + + // Position and draw nodes + Object.entries(generations).forEach(([generation, persons]) => { + const y = yScale(generation) + yScale.bandwidth() / 2; + + persons.forEach((person, index) => { + const x = person.estimatedYear ? + xScale(person.estimatedYear) : + padding + (index * 30); // Fallback positioning + + person.x = x; + person.y = y; + }); + }); + + this.drawTimelineConnections(timelineData); + this.drawNodes(timelineData, rootPersonId); + this.addGenerationLabels(generations, 20, 0, yScale.bandwidth()); + } + + /** + * Circular Pedigree Layout - Traditional pedigree chart in circular form + */ + renderCircularPedigreeLayout(rootPersonId) { + this.currentLayout = 'circular-pedigree'; + this.updateDimensions(); + + const pedigreeData = this.buildPedigreeData(rootPersonId, 5); + if (!pedigreeData) return; + + this.g.selectAll("*").remove(); + + const centerX = this.width / 2; + const centerY = this.height / 2; + const maxRadius = Math.min(this.width, this.height) / 2 - 60; + + // Calculate positions for pedigree chart + this.positionPedigreeNodes(pedigreeData, centerX, centerY, maxRadius); + + this.drawPedigreeConnections(pedigreeData); + this.drawNodes([pedigreeData], rootPersonId); + } + + // Helper Methods + + buildRadialTreeData(rootPersonId, maxGenerations) { + const nodes = []; + const visited = new Set(); + + const traverse = (personId, generation) => { + if (!personId || visited.has(personId) || generation > maxGenerations) return null; + + visited.add(personId); + const person = this.familyData[personId]; + if (!person) return null; + + const node = { + id: personId, + name: person.name, + data: person, + gender: this.determineGender(person), + generation: generation, + children: [] + }; + + nodes.push(node); + + // Add children + if (person.children && generation < maxGenerations) { + person.children.forEach(childId => { + const child = traverse(childId, generation + 1); + if (child) node.children.push(child); + }); + } + + return node; + }; + + const rootNode = traverse(rootPersonId, 0); + return { root: rootNode, nodes: nodes }; + } + + buildGraphData(rootPersonId, includeExtended) { + const nodes = []; + const links = []; + const visited = new Set(); + const maxDepth = includeExtended ? 3 : 2; + + const traverse = (personId, depth) => { + if (!personId || visited.has(personId) || depth > maxDepth) return; + + visited.add(personId); + const person = this.familyData[personId]; + if (!person) return; + + const node = { + id: personId, + name: person.name, + data: person, + gender: this.determineGender(person) + }; + nodes.push(node); + + // Add family links + if (person.spouse) { + const spouseId = this.findPersonIdByName(person.spouse); + if (spouseId && !visited.has(spouseId)) { + traverse(spouseId, depth); + links.push({ + source: personId, + target: spouseId, + type: 'marriage' + }); + } + } + + // Add parent-child links + if (person.children && depth < maxDepth) { + person.children.forEach(childId => { + traverse(childId, depth + 1); + links.push({ + source: personId, + target: childId, + type: 'parent-child' + }); + }); + } + + // Add parent links + if (person.parents && depth < maxDepth) { + person.parents.forEach(parentId => { + traverse(parentId, depth + 1); + links.push({ + source: parentId, + target: personId, + type: 'parent-child' + }); + }); + } + }; + + traverse(rootPersonId, 0); + return { nodes, links }; + } + + buildTimelineData(rootPersonId) { + const persons = []; + const visited = new Set(); + + const traverse = (personId, generation = 0) => { + if (!personId || visited.has(personId)) return; + + visited.add(personId); + const person = this.familyData[personId]; + if (!person) return; + + const estimatedYear = this.estimateBirthYear(person); + persons.push({ + id: personId, + name: person.name, + data: person, + gender: this.determineGender(person), + generation: generation, + estimatedYear: estimatedYear + }); + + // Traverse family members + if (person.children) { + person.children.forEach(childId => traverse(childId, generation + 1)); + } + if (person.parents) { + person.parents.forEach(parentId => traverse(parentId, generation - 1)); + } + }; + + traverse(rootPersonId); + return persons; + } + + getGenerationLevels(treeData) { + const generations = {}; + + const addToGeneration = (node) => { + const gen = node.generation || 0; + if (!generations[gen]) generations[gen] = []; + generations[gen].push(node); + + if (node.children) { + node.children.forEach(addToGeneration); + } + }; + + if (treeData.nodes) { + treeData.nodes.forEach(addToGeneration); + } + + return Object.values(generations); + } + + drawRadialConnections(treeData) { + // Draw curved connections between generations + const connections = []; + + const addConnections = (node) => { + if (node.children) { + node.children.forEach(child => { + connections.push({ source: node, target: child }); + addConnections(child); + }); + } + }; + + if (treeData.root) addConnections(treeData.root); + + this.g.selectAll('.radial-link') + .data(connections) + .enter() + .append('path') + .attr('class', 'radial-link') + .attr('d', d => { + const midX = (d.source.x + d.target.x) / 2; + const midY = (d.source.y + d.target.y) / 2; + return `M${d.source.x},${d.source.y} Q${midX},${midY} ${d.target.x},${d.target.y}`; + }) + .attr('stroke', '#666') + .attr('stroke-width', 2) + .attr('fill', 'none'); + } + + drawNodes(nodes, currentPersonId) { + const nodeSelection = this.g.selectAll('.tree-node') + .data(nodes) + .enter() + .append('g') + .attr('class', d => `tree-node ${d.gender} ${d.id === currentPersonId ? 'current' : ''}`) + .attr('transform', d => `translate(${d.x},${d.y})`) + .on('click', (event, d) => { + if (this.selectPerson) this.selectPerson(d.id); + }); + + nodeSelection.append('circle') + .attr('r', d => d.id === currentPersonId ? 10 : 6) + .attr('fill', d => this.getNodeColor(d, currentPersonId)); + + nodeSelection.append('text') + .attr('dy', '.35em') + .attr('text-anchor', 'middle') + .style('font-size', '10px') + .style('font-weight', d => d.id === currentPersonId ? 'bold' : 'normal') + .text(d => this.truncateName(d.name, 12)); + } + + // Utility Methods + + determineGender(person) { + const name = person.name.toLowerCase(); + const femaleNames = ['eve', 'sarah', 'rebekah', 'rachel', 'leah', 'mary', 'elizabeth']; + return femaleNames.includes(name) ? 'female' : 'male'; + } + + getNodeColor(node, currentPersonId) { + if (node.id === currentPersonId) return '#007bff'; + return node.gender === 'female' ? '#e91e63' : '#2196f3'; + } + + getLinkColor(type) { + switch(type) { + case 'marriage': return '#4caf50'; + case 'parent-child': return '#666'; + default: return '#999'; + } + } + + getLinkDistance(link) { + return link.type === 'marriage' ? 80 : 120; + } + + getLinkStrength(link) { + return link.type === 'marriage' ? 0.8 : 0.5; + } + + truncateName(name, maxLength) { + return name.length > maxLength ? name.substring(0, maxLength) + '...' : name; + } + + estimateBirthYear(person) { + // Simple estimation based on biblical timeline + // This would need more sophisticated logic based on actual biblical data + const birthYear = person.birth_year; + if (birthYear && birthYear !== "Unknown") { + const match = birthYear.match(/\d+/); + return match ? parseInt(match[0]) : null; + } + return null; + } + + findPersonIdByName(name) { + return Object.keys(this.familyData).find(id => + this.familyData[id].name === name + ); + } + + // Force simulation event handlers + dragStarted(event, d) { + if (!event.active) this.simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + } + + dragged(event, d) { + d.fx = event.x; + d.fy = event.y; + } + + dragEnded(event, d) { + if (!event.active) this.simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + } + + // Public API + setSelectPersonCallback(callback) { + this.selectPerson = callback; + } + + getCurrentLayout() { + return this.currentLayout; + } + + centerView() { + const bounds = this.g.node().getBBox(); + const parent = this.svg.node().getBoundingClientRect(); + const fullWidth = parent.width; + const fullHeight = parent.height; + const width = bounds.width; + const height = bounds.height; + const midX = bounds.x + width / 2; + const midY = bounds.y + height / 2; + + const scale = 0.8 / Math.max(width / fullWidth, height / fullHeight); + const translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY]; + + this.svg.transition() + .duration(750) + .call(this.zoom.transform, d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)); + } + + destroy() { + if (this.simulation) { + this.simulation.stop(); + } + } +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = AdvancedTreeLayouts; +} \ No newline at end of file diff --git a/kjvstudy_org/static/js/family-tree-analytics.js b/kjvstudy_org/static/js/family-tree-analytics.js new file mode 100644 index 0000000..5c56f30 --- /dev/null +++ b/kjvstudy_org/static/js/family-tree-analytics.js @@ -0,0 +1,863 @@ +/** + * Family Tree Analytics and Statistics System for KJV Study + * Provides comprehensive statistical analysis and insights with interactive charts + */ + +class FamilyTreeAnalytics { + constructor(familyData) { + this.familyData = familyData; + this.chartInstances = {}; + this.analysisCache = {}; + + this.initializeAnalytics(); + this.calculateStatistics(); + } + + initializeAnalytics() { + this.createAnalyticsInterface(); + this.setupEventListeners(); + } + + createAnalyticsInterface() { + const analyticsContainer = document.createElement('div'); + analyticsContainer.className = 'family-analytics-container'; + analyticsContainer.innerHTML = ` +
+

Family Tree Analytics

+
+ + + +
+
+ +
+ +
+
+
+
+
0
+
Total Persons
+
+
+ +
+
+
+
0
+
Male
+
+
+ +
+
+
+
0
+
Female
+
+
+ +
+
+
+
0
+
Generations
+
+
+ +
+
+
+
0
+
Families
+
+
+ +
+
+
+
0
+
Avg Children
+
+
+
+ + +
+ + + + + +
+ + +
+ +
+
+

Demographic Analysis

+
+ +
+
+
+ +
+
+
+ Gender Ratio: Loading... +
+
+ Most Common Names: Loading... +
+
+
+ + +
+
+

Generational Distribution

+
+ +
+
+
+ +
+
+
+
+
+ + +
+
+

Family Relationships

+
+
+
+
+
0
+
Married Couples
+
+
+
0
+
Single Parents
+
+
+
0
+
Childless Couples
+
+
+
0
+
Largest Family
+
+
+
+
+ +
+
+ + +
+
+

Biblical Timeline Analysis

+
+ +
+
+
+ +
+
+
+
+
Longest Lifespan
+
Loading...
+
+
+
Shortest Lifespan
+
Loading...
+
+
+
Average Lifespan
+
Loading...
+
+
+
+
+ + +
+
+

Longevity Analysis

+
+
+ +
+ +
+
+ + +
+

Detailed Insights

+
+
+
Family Patterns
+
    +
    +
    +
    Notable Statistics
    +
      +
      +
      +
      Genealogical Insights
      +
        +
        +
        +
        +
        + `; + + // Insert analytics container into the page + const familyViewer = document.querySelector('.family-viewer'); + if (familyViewer) { + familyViewer.appendChild(analyticsContainer); + } + } + + setupEventListeners() { + // Tab switching + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + this.switchTab(e.target.dataset.tab); + }); + }); + + // Chart type changes + document.getElementById('demographic-chart-type')?.addEventListener('change', (e) => { + this.updateDemographicChart(e.target.value); + }); + + document.getElementById('generational-metric')?.addEventListener('change', (e) => { + this.updateGenerationalChart(e.target.value); + }); + + document.getElementById('timeline-view')?.addEventListener('change', (e) => { + this.updateTimelineChart(e.target.value); + }); + + // Control buttons + document.getElementById('refresh-analytics')?.addEventListener('click', () => { + this.refreshAnalytics(); + }); + + document.getElementById('export-analytics')?.addEventListener('click', () => { + this.exportAnalyticsReport(); + }); + + document.getElementById('toggle-analytics')?.addEventListener('click', () => { + this.toggleAnalyticsPanel(); + }); + } + + calculateStatistics() { + this.stats = { + totalPersons: Object.keys(this.familyData).length, + genderDistribution: this.calculateGenderDistribution(), + generationData: this.calculateGenerationData(), + familyStructure: this.calculateFamilyStructure(), + lifespanData: this.calculateLifespanData(), + relationshipMetrics: this.calculateRelationshipMetrics(), + nameAnalysis: this.calculateNameAnalysis(), + biblicalTimeline: this.calculateBiblicalTimeline() + }; + + this.updateOverviewStats(); + this.generateInsights(); + this.createCharts(); + } + + calculateGenderDistribution() { + const genders = { male: 0, female: 0, unknown: 0 }; + + Object.values(this.familyData).forEach(person => { + const gender = this.determineGender(person); + genders[gender]++; + }); + + return genders; + } + + calculateGenerationData() { + const generations = {}; + const visited = new Set(); + + const mapGeneration = (personId, generation = 0) => { + if (visited.has(personId)) return; + visited.add(personId); + + const person = this.familyData[personId]; + if (!person) return; + + if (!generations[generation]) { + generations[generation] = { + count: 0, + persons: [], + totalLifespan: 0, + lifespanCount: 0, + totalChildren: 0 + }; + } + + generations[generation].count++; + generations[generation].persons.push(personId); + generations[generation].totalChildren += (person.children?.length || 0); + + // Calculate lifespan if available + const lifespan = this.calculateLifespan(person); + if (lifespan > 0) { + generations[generation].totalLifespan += lifespan; + generations[generation].lifespanCount++; + } + + // Map children to next generation + if (person.children) { + person.children.forEach(childId => { + mapGeneration(childId, generation + 1); + }); + } + }; + + // Start with root figures (those without parents) + Object.entries(this.familyData).forEach(([id, person]) => { + if (!person.parents || person.parents.length === 0) { + mapGeneration(id, 0); + } + }); + + return generations; + } + + calculateFamilyStructure() { + let marriedCouples = 0; + let singleParents = 0; + let childlessCouples = 0; + let largestFamily = 0; + const familySizes = []; + + Object.values(this.familyData).forEach(person => { + const childrenCount = person.children?.length || 0; + + if (childrenCount > 0) { + familySizes.push(childrenCount); + largestFamily = Math.max(largestFamily, childrenCount); + + if (person.spouse) { + marriedCouples++; + } else { + singleParents++; + } + } else if (person.spouse) { + childlessCouples++; + } + }); + + return { + marriedCouples: Math.floor(marriedCouples / 2), // Avoid double counting + singleParents, + childlessCouples: Math.floor(childlessCouples / 2), + largestFamily, + familySizes, + averageChildren: familySizes.length > 0 ? + (familySizes.reduce((a, b) => a + b, 0) / familySizes.length).toFixed(1) : 0 + }; + } + + calculateLifespanData() { + const lifespans = []; + let totalLifespan = 0; + let lifespanCount = 0; + let longestLived = { name: '', years: 0 }; + let shortestLived = { name: '', years: Infinity }; + + Object.values(this.familyData).forEach(person => { + const lifespan = this.calculateLifespan(person); + if (lifespan > 0) { + lifespans.push({ name: person.name, years: lifespan }); + totalLifespan += lifespan; + lifespanCount++; + + if (lifespan > longestLived.years) { + longestLived = { name: person.name, years: lifespan }; + } + if (lifespan < shortestLived.years) { + shortestLived = { name: person.name, years: lifespan }; + } + } + }); + + return { + lifespans, + averageLifespan: lifespanCount > 0 ? (totalLifespan / lifespanCount).toFixed(1) : 0, + longestLived: longestLived.years > 0 ? longestLived : null, + shortestLived: shortestLived.years < Infinity ? shortestLived : null + }; + } + + calculateRelationshipMetrics() { + const relationships = { + parentChild: 0, + spouses: 0, + siblings: 0 + }; + + Object.values(this.familyData).forEach(person => { + relationships.parentChild += person.children?.length || 0; + if (person.spouse) relationships.spouses++; + }); + + relationships.spouses = Math.floor(relationships.spouses / 2); // Avoid double counting + + return relationships; + } + + calculateNameAnalysis() { + const nameFrequency = {}; + const nameComponents = {}; + + Object.values(this.familyData).forEach(person => { + const name = person.name.toLowerCase(); + nameFrequency[name] = (nameFrequency[name] || 0) + 1; + + // Analyze name components + const parts = name.split(' '); + parts.forEach(part => { + if (part.length > 2) { + nameComponents[part] = (nameComponents[part] || 0) + 1; + } + }); + }); + + const commonNames = Object.entries(nameFrequency) + .sort(([,a], [,b]) => b - a) + .slice(0, 5) + .map(([name, count]) => ({ name, count })); + + return { nameFrequency, nameComponents, commonNames }; + } + + calculateBiblicalTimeline() { + const timeline = []; + + Object.values(this.familyData).forEach(person => { + const birthYear = this.parseBiblicalYear(person.birth_year); + const deathYear = this.parseBiblicalYear(person.death_year); + + if (birthYear) { + timeline.push({ + name: person.name, + birthYear, + deathYear, + lifespan: deathYear ? deathYear - birthYear : null + }); + } + }); + + return timeline.sort((a, b) => (b.birthYear || 0) - (a.birthYear || 0)); + } + + updateOverviewStats() { + document.getElementById('total-persons').textContent = this.stats.totalPersons; + document.getElementById('male-count').textContent = this.stats.genderDistribution.male; + document.getElementById('female-count').textContent = this.stats.genderDistribution.female; + document.getElementById('generations-count').textContent = Object.keys(this.stats.generationData).length; + document.getElementById('families-count').textContent = this.stats.familyStructure.marriedCouples; + document.getElementById('avg-children').textContent = this.stats.familyStructure.averageChildren; + } + + createCharts() { + this.createDemographicChart(); + this.createGenerationalChart(); + this.createRelationshipsChart(); + this.createTimelineChart(); + this.createLongevityChart(); + } + + createDemographicChart(type = 'pie') { + const ctx = document.getElementById('demographic-chart'); + if (!ctx) return; + + if (this.chartInstances.demographic) { + this.chartInstances.demographic.destroy(); + } + + const data = { + labels: ['Male', 'Female', 'Unknown'], + datasets: [{ + data: [ + this.stats.genderDistribution.male, + this.stats.genderDistribution.female, + this.stats.genderDistribution.unknown + ], + backgroundColor: ['#2196F3', '#E91E63', '#9E9E9E'], + borderWidth: 2, + borderColor: '#fff' + }] + }; + + this.chartInstances.demographic = new Chart(ctx, { + type: type, + data: data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' + }, + title: { + display: true, + text: 'Gender Distribution' + } + } + } + }); + + this.updateDemographicInsights(); + } + + createGenerationalChart(metric = 'count') { + const ctx = document.getElementById('generational-chart'); + if (!ctx) return; + + if (this.chartInstances.generational) { + this.chartInstances.generational.destroy(); + } + + const generations = this.stats.generationData; + const labels = Object.keys(generations).map(gen => `Generation ${gen}`); + let dataValues = []; + let label = ''; + + switch(metric) { + case 'count': + dataValues = Object.values(generations).map(gen => gen.count); + label = 'Number of Persons'; + break; + case 'lifespan': + dataValues = Object.values(generations).map(gen => + gen.lifespanCount > 0 ? (gen.totalLifespan / gen.lifespanCount).toFixed(1) : 0 + ); + label = 'Average Lifespan (years)'; + break; + case 'children': + dataValues = Object.values(generations).map(gen => + gen.count > 0 ? (gen.totalChildren / gen.count).toFixed(1) : 0 + ); + label = 'Average Children per Person'; + break; + } + + this.chartInstances.generational = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [{ + label: label, + data: dataValues, + backgroundColor: '#4CAF50', + borderColor: '#388E3C', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true + } + }, + plugins: { + title: { + display: true, + text: `Generational Analysis - ${label}` + } + } + } + }); + + this.updateGenerationBreakdown(); + } + + createRelationshipsChart() { + const ctx = document.getElementById('relationships-chart'); + if (!ctx) return; + + if (this.chartInstances.relationships) { + this.chartInstances.relationships.destroy(); + } + + const familySizes = this.stats.familyStructure.familySizes; + const distribution = {}; + + familySizes.forEach(size => { + const key = size > 10 ? '10+' : size.toString(); + distribution[key] = (distribution[key] || 0) + 1; + }); + + this.chartInstances.relationships = new Chart(ctx, { + type: 'bar', + data: { + labels: Object.keys(distribution).sort((a, b) => { + if (a === '10+') return 1; + if (b === '10+') return -1; + return parseInt(a) - parseInt(b); + }), + datasets: [{ + label: 'Number of Families', + data: Object.values(distribution), + backgroundColor: '#FF9800', + borderColor: '#F57C00', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + title: { + display: true, + text: 'Number of Children' + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: 'Number of Families' + } + } + }, + plugins: { + title: { + display: true, + text: 'Family Size Distribution' + } + } + } + }); + + this.updateRelationshipMetrics(); + } + + createTimelineChart(view = 'births') { + const ctx = document.getElementById('timeline-chart'); + if (!ctx) return; + + if (this.chartInstances.timeline) { + this.chartInstances.timeline.destroy(); + } + + const timeline = this.stats.biblicalTimeline.filter(person => person.birthYear); + + if (timeline.length === 0) { + ctx.getContext('2d').clearRect(0, 0, ctx.width, ctx.height); + return; + } + + let chartData = {}; + + switch(view) { + case 'births': + chartData = this.createBirthTimelineData(timeline); + break; + case 'lifespans': + chartData = this.createLifespanTimelineData(timeline); + break; + case 'generations': + chartData = this.createGenerationOverlapData(timeline); + break; + } + + this.chartInstances.timeline = new Chart(ctx, chartData); + this.updateTimelineInsights(); + } + + createLongevityChart() { + const ctx = document.getElementById('longevity-chart'); + if (!ctx) return; + + if (this.chartInstances.longevity) { + this.chartInstances.longevity.destroy(); + } + + const lifespans = this.stats.lifespanData.lifespans; + if (lifespans.length === 0) return; + + // Create age groups + const ageGroups = { + '0-100': 0, '101-200': 0, '201-300': 0, '301-400': 0, + '401-500': 0, '501-600': 0, '601-700': 0, '700+': 0 + }; + + lifespans.forEach(person => { + const age = person.years; + if (age <= 100) ageGroups['0-100']++; + else if (age <= 200) ageGroups['101-200']++; + else if (age <= 300) ageGroups['201-300']++; + else if (age <= 400) ageGroups['301-400']++; + else if (age <= 500) ageGroups['401-500']++; + else if (age <= 600) ageGroups['501-600']++; + else if (age <= 700) ageGroups['601-700']++; + else ageGroups['700+']++; + }); + + this.chartInstances.longevity = new Chart(ctx, { + type: 'doughnut', + data: { + labels: Object.keys(ageGroups), + datasets: [{ + data: Object.values(ageGroups), + backgroundColor: [ + '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', + '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F' + ] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Longevity Distribution (Years)' + }, + legend: { + position: 'bottom' + } + } + } + }); + + this.updateLongevityTrends(); + } + + // Helper Methods + + determineGender(person) { + const name = person.name.toLowerCase(); + const femaleNames = [ + 'eve', 'sarah', 'sarai', 'rebekah', 'rebecca', 'rachel', 'leah', 'dinah', + 'tamar', 'miriam', 'deborah', 'ruth', 'naomi', 'bathsheba', 'abigail', + 'esther', 'mary', 'elizabeth', 'anna', 'hannah', 'martha' + ]; + + if (femaleNames.some(femName => name.includes(femName))) { + return 'female'; + } + + // Check title/description for gender clues + const description = (person.description || '').toLowerCase(); + const title = (person.title || '').toLowerCase(); + + if (description.includes('wife') || title.includes('wife') || + description.includes('mother') || title.includes('mother')) { + return 'female'; + } + + return 'male'; // Default for biblical genealogies + } + + calculateLifespan(person) { + const birthYear = this.parseBiblicalYear(person.birth_year); + const deathYear = this.parseBiblicalYear(person.death_year); + + if (birthYear && deathYear && deathYear > birthYear) { + return deathYear - birthYear; + } + + // Try to extract from age_at_death + if (person.age_at_death && person.age_at_death !== "Unknown") { + const match = person.age_at_death.match(/(\d+)/); + if (match) { + return parseInt(match[1]); + } + } + + return 0; + } + + parseBiblicalYear(yearString) { + if (!yearString || yearString === "Unknown") return null; + + const match = yearString.match(/(\d+)/); + return match ? parseInt(match[1]) : null; + } + + // UI Update Methods + + switchTab(tabName) { + // Update tab buttons + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + + // Update panels + document.querySelectorAll('.chart-panel').forEach(panel => { + panel.classList.toggle('active', panel.id === `${tabName}-panel`); + }); + } + + updateDemographicChart(type) { + this.createDemographicChart(type); + } + + updateGenerationalChart(metric) { + this.createGenerationalChart(metric); + } + + updateTimelineChart(view) { + this.createTimelineChart(view); + } + + updateDemographicInsights() { + const total = this.stats.genderDistribution.male + this.stats.genderDistribution.female; + const ratio = total > 0 ? + `${(this.stats.genderDistribution.male / total * 100).toFixed(1)}% Male, ${(this.stats.genderDistribution.female / total * 100).toFixed(1)}% Female` : + 'No data'; + + document.getElementById('gender-ratio').textContent = ratio; + + const commonNames = this.stats.nameAnalysis.commonNames + .slice(0, 3) + .map(item => item.name) + .join(', '); + + document.getElementById(' \ No newline at end of file diff --git a/kjvstudy_org/static/js/family-tree-search.js b/kjvstudy_org/static/js/family-tree-search.js new file mode 100644 index 0000000..5ac8691 --- /dev/null +++ b/kjvstudy_org/static/js/family-tree-search.js @@ -0,0 +1,916 @@ +/** + * Advanced Search and Navigation System for KJV Study Family Tree + * Provides comprehensive search capabilities with tree highlighting and breadcrumb navigation + */ + +class FamilyTreeSearch { + constructor(familyData, treeVisualization) { + this.familyData = familyData; + this.treeVisualization = treeVisualization; + this.searchResults = []; + this.currentHighlights = []; + this.searchHistory = []; + this.bookmarks = JSON.parse(localStorage.getItem('familyTreeBookmarks')) || []; + this.breadcrumbs = []; + + this.initializeSearch(); + this.initializeNavigation(); + this.loadBookmarks(); + } + + initializeSearch() { + // Create search interface + this.createSearchInterface(); + this.setupSearchEventListeners(); + this.buildSearchIndex(); + } + + createSearchInterface() { + const searchContainer = document.createElement('div'); + searchContainer.className = 'family-search-container'; + searchContainer.innerHTML = ` +
        +
        + + + +
        + +
        +
        + + + + + +
        + +
        + + + + +
        + +
        + + +
        +
        + +
        +
        + 0 results +
        + + +
        +
        +
        +
        +
        + + + `; + + // Insert search container into the page + const familyViewer = document.querySelector('.family-viewer'); + if (familyViewer) { + familyViewer.insertBefore(searchContainer, familyViewer.firstChild); + } + } + + setupSearchEventListeners() { + const searchInput = document.getElementById('family-search-input'); + const clearBtn = document.getElementById('search-clear-btn'); + const advancedToggle = document.getElementById('advanced-search-toggle'); + const highlightBtn = document.getElementById('highlight-all-btn'); + const exportBtn = document.getElementById('export-results-btn'); + + // Search input with debouncing + let searchTimeout; + searchInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + this.performSearch(e.target.value); + }, 300); + }); + + // Clear search + clearBtn.addEventListener('click', () => { + searchInput.value = ''; + this.clearSearch(); + }); + + // Toggle advanced filters + advancedToggle.addEventListener('click', () => { + const filters = document.querySelector('.search-filters'); + filters.classList.toggle('visible'); + }); + + // Filter change events + document.querySelectorAll('input[name="searchFields"], input[name="genderFilter"]').forEach(input => { + input.addEventListener('change', () => { + if (searchInput.value) { + this.performSearch(searchInput.value); + } + }); + }); + + document.getElementById('generation-filter').addEventListener('change', () => { + if (searchInput.value) { + this.performSearch(searchInput.value); + } + }); + + // Result actions + highlightBtn.addEventListener('click', () => this.highlightAllResults()); + exportBtn.addEventListener('click', () => this.exportSearchResults()); + } + + buildSearchIndex() { + this.searchIndex = {}; + Object.entries(this.familyData).forEach(([id, person]) => { + this.searchIndex[id] = { + id: id, + name: person.name.toLowerCase(), + title: (person.title || '').toLowerCase(), + description: (person.description || '').toLowerCase(), + verses: this.extractVerseText(person.verses || []).toLowerCase(), + gender: this.determineGender(person), + searchText: [ + person.name, + person.title || '', + person.description || '', + this.extractVerseText(person.verses || []) + ].join(' ').toLowerCase() + }; + }); + } + + performSearch(query) { + if (!query.trim()) { + this.clearSearch(); + return; + } + + const searchFields = this.getSelectedSearchFields(); + const genderFilter = this.getSelectedGenderFilter(); + const generationFilter = this.getGenerationFilter(); + + this.searchResults = this.executeSearch(query, searchFields, genderFilter, generationFilter); + this.displaySearchResults(); + this.updateResultsCount(); + + // Add to search history + this.addToSearchHistory(query); + } + + executeSearch(query, searchFields, genderFilter, generationFilter) { + const queryLower = query.toLowerCase(); + const queryTerms = queryLower.split(/\s+/).filter(term => term.length > 0); + + return Object.values(this.searchIndex).filter(person => { + // Gender filter + if (genderFilter !== 'all' && person.gender !== genderFilter) { + return false; + } + + // Generation filter (would need current person context) + if (generationFilter !== 'all') { + // Implementation depends on current tree state + // This is a placeholder for generation-based filtering + } + + // Text search + const matchScore = this.calculateMatchScore(person, queryTerms, searchFields); + return matchScore > 0; + }).sort((a, b) => { + // Sort by relevance score + const scoreA = this.calculateMatchScore(a, queryTerms, searchFields); + const scoreB = this.calculateMatchScore(b, queryTerms, searchFields); + return scoreB - scoreA; + }); + } + + calculateMatchScore(person, queryTerms, searchFields) { + let score = 0; + + queryTerms.forEach(term => { + searchFields.forEach(field => { + const fieldValue = person[field] || ''; + if (fieldValue.includes(term)) { + // Exact name matches get highest score + if (field === 'name' && fieldValue === term) { + score += 10; + } + // Name contains term gets high score + else if (field === 'name') { + score += 5; + } + // Other fields get standard score + else { + score += 1; + } + } + }); + }); + + return score; + } + + displaySearchResults() { + const resultsList = document.getElementById('search-results-list'); + resultsList.innerHTML = ''; + + if (this.searchResults.length === 0) { + resultsList.innerHTML = '
        No results found
        '; + return; + } + + this.searchResults.forEach((result, index) => { + const person = this.familyData[result.id]; + const resultElement = document.createElement('div'); + resultElement.className = 'search-result-item'; + resultElement.innerHTML = ` +
        +
        ${person.name}
        +
        ${person.title || 'Biblical Figure'}
        +
        ${this.createSearchSnippet(person)}
        +
        +
        + + + +
        + `; + + // Add event listeners + resultElement.querySelector('.select-person-btn').addEventListener('click', () => { + this.selectPerson(result.id); + }); + + resultElement.querySelector('.highlight-person-btn').addEventListener('click', () => { + this.highlightPersonInTree(result.id); + }); + + resultElement.querySelector('.bookmark-person-btn').addEventListener('click', () => { + this.addBookmark(result.id); + }); + + resultsList.appendChild(resultElement); + }); + } + + createSearchSnippet(person) { + const snippetLength = 100; + let snippet = person.description || ''; + + if (snippet.length > snippetLength) { + snippet = snippet.substring(0, snippetLength) + '...'; + } + + return snippet || 'Biblical figure in genealogy'; + } + + // Navigation Methods + + initializeNavigation() { + this.navigationHistory = []; + this.navigationIndex = -1; + this.setupNavigationEventListeners(); + } + + setupNavigationEventListeners() { + document.getElementById('nav-root').addEventListener('click', () => this.navigateToRoot()); + document.getElementById('nav-back').addEventListener('click', () => this.navigateBack()); + document.getElementById('nav-forward').addEventListener('click', () => this.navigateForward()); + document.getElementById('clear-breadcrumbs').addEventListener('click', () => this.clearBreadcrumbs()); + document.getElementById('add-bookmark').addEventListener('click', () => this.addCurrentBookmark()); + document.getElementById('manage-bookmarks').addEventListener('click', () => this.openBookmarkManager()); + } + + addToBreadcrumbs(personId) { + const person = this.familyData[personId]; + if (!person) return; + + // Avoid duplicate consecutive entries + if (this.breadcrumbs.length > 0 && this.breadcrumbs[this.breadcrumbs.length - 1].id === personId) { + return; + } + + this.breadcrumbs.push({ + id: personId, + name: person.name, + timestamp: Date.now() + }); + + // Limit breadcrumb history + if (this.breadcrumbs.length > 10) { + this.breadcrumbs.shift(); + } + + this.updateBreadcrumbDisplay(); + } + + updateBreadcrumbDisplay() { + const breadcrumbTrail = document.getElementById('breadcrumb-trail'); + breadcrumbTrail.innerHTML = ''; + + this.breadcrumbs.forEach((crumb, index) => { + const crumbElement = document.createElement('div'); + crumbElement.className = 'breadcrumb-item'; + crumbElement.innerHTML = ` + ${crumb.name} + + `; + + crumbElement.querySelector('.breadcrumb-select').addEventListener('click', () => { + this.selectPerson(crumb.id); + }); + + breadcrumbTrail.appendChild(crumbElement); + + // Add separator + if (index < this.breadcrumbs.length - 1) { + const separator = document.createElement('span'); + separator.className = 'breadcrumb-separator'; + separator.textContent = '→'; + breadcrumbTrail.appendChild(separator); + } + }); + } + + // Bookmark Management + + addBookmark(personId) { + const person = this.familyData[personId]; + if (!person) return; + + const bookmark = { + id: personId, + name: person.name, + title: person.title || 'Biblical Figure', + timestamp: Date.now() + }; + + // Check if already bookmarked + if (!this.bookmarks.find(b => b.id === personId)) { + this.bookmarks.push(bookmark); + this.saveBookmarks(); + this.updateBookmarksDisplay(); + this.showNotification(`${person.name} added to bookmarks`); + } else { + this.showNotification(`${person.name} is already bookmarked`); + } + } + + removeBookmark(personId) { + this.bookmarks = this.bookmarks.filter(b => b.id !== personId); + this.saveBookmarks(); + this.updateBookmarksDisplay(); + } + + saveBookmarks() { + localStorage.setItem('familyTreeBookmarks', JSON.stringify(this.bookmarks)); + } + + loadBookmarks() { + this.updateBookmarksDisplay(); + } + + updateBookmarksDisplay() { + const bookmarksList = document.getElementById('bookmarks-list'); + bookmarksList.innerHTML = ''; + + if (this.bookmarks.length === 0) { + bookmarksList.innerHTML = '
        No bookmarks yet
        '; + return; + } + + this.bookmarks.forEach(bookmark => { + const bookmarkElement = document.createElement('div'); + bookmarkElement.className = 'bookmark-item'; + bookmarkElement.innerHTML = ` +
        +
        ${bookmark.name}
        +
        ${bookmark.title}
        +
        +
        + + +
        + `; + + bookmarkElement.querySelector('.bookmark-select').addEventListener('click', () => { + this.selectPerson(bookmark.id); + }); + + bookmarkElement.querySelector('.bookmark-remove').addEventListener('click', () => { + this.removeBookmark(bookmark.id); + }); + + bookmarksList.appendChild(bookmarkElement); + }); + } + + // Tree Highlighting + + highlightPersonInTree(personId) { + // Remove existing highlights + this.clearHighlights(); + + // Add new highlight + this.currentHighlights.push(personId); + + // Update tree visualization + if (this.treeVisualization && this.treeVisualization.highlightNode) { + this.treeVisualization.highlightNode(personId); + } + } + + highlightAllResults() { + this.clearHighlights(); + + this.currentHighlights = this.searchResults.map(result => result.id); + + if (this.treeVisualization && this.treeVisualization.highlightNodes) { + this.treeVisualization.highlightNodes(this.currentHighlights); + } + } + + clearHighlights() { + this.currentHighlights = []; + + if (this.treeVisualization && this.treeVisualization.clearHighlights) { + this.treeVisualization.clearHighlights(); + } + } + + // Utility Methods + + getSelectedSearchFields() { + const checkboxes = document.querySelectorAll('input[name="searchFields"]:checked'); + return Array.from(checkboxes).map(cb => cb.value); + } + + getSelectedGenderFilter() { + const radio = document.querySelector('input[name="genderFilter"]:checked'); + return radio ? radio.value : 'all'; + } + + getGenerationFilter() { + const select = document.getElementById('generation-filter'); + return select ? select.value : 'all'; + } + + extractVerseText(verses) { + if (!Array.isArray(verses)) return ''; + return verses.map(verse => verse.text || '').join(' '); + } + + determineGender(person) { + const name = person.name.toLowerCase(); + const femaleNames = ['eve', 'sarah', 'rebekah', 'rachel', 'leah', 'mary', 'elizabeth']; + return femaleNames.includes(name) ? 'female' : 'male'; + } + + updateResultsCount() { + const countElement = document.querySelector('.results-count'); + if (countElement) { + countElement.textContent = `${this.searchResults.length} result${this.searchResults.length !== 1 ? 's' : ''}`; + } + } + + clearSearch() { + this.searchResults = []; + this.displaySearchResults(); + this.updateResultsCount(); + this.clearHighlights(); + } + + addToSearchHistory(query) { + if (!this.searchHistory.includes(query)) { + this.searchHistory.unshift(query); + if (this.searchHistory.length > 20) { + this.searchHistory.pop(); + } + } + } + + showNotification(message) { + // Simple notification system + const notification = document.createElement('div'); + notification.className = 'search-notification'; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + notification.remove(); + }, 3000); + } + + exportSearchResults() { + if (this.searchResults.length === 0) { + this.showNotification('No search results to export'); + return; + } + + const csvContent = this.generateSearchResultsCSV(); + this.downloadCSV(csvContent, 'family_tree_search_results.csv'); + } + + generateSearchResultsCSV() { + const headers = ['Name', 'Title', 'Description', 'Birth Year', 'Death Year']; + const rows = [headers]; + + this.searchResults.forEach(result => { + const person = this.familyData[result.id]; + rows.push([ + person.name, + person.title || '', + person.description || '', + person.birth_year || '', + person.death_year || '' + ]); + }); + + return rows.map(row => row.map(field => `"${field}"`).join(',')).join('\n'); + } + + downloadCSV(content, filename) { + const blob = new Blob([content], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + } + + // Public API + + selectPerson(personId) { + this.addToBreadcrumbs(personId); + + // Call the external select person function + if (window.selectPerson) { + window.selectPerson(personId); + } + } + + getCurrentHighlights() { + return [...this.currentHighlights]; + } + + getSearchResults() { + return [...this.searchResults]; + } + + setTreeVisualization(treeViz) { + this.treeVisualization = treeViz; + } +} + +// CSS Styles for Search Interface +const searchStyles = ` + +`; + +// Inject styles +document.head.insertAdjacentHTML('beforeend', searchStyles); + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = FamilyTreeSearch; +} \ No newline at end of file