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