mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="analytics-header">
|
||||
<h3><i class="fas fa-chart-bar"></i> Family Tree Analytics</h3>
|
||||
<div class="analytics-controls">
|
||||
<button id="refresh-analytics" class="analytics-btn" title="Refresh statistics">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
<button id="export-analytics" class="analytics-btn" title="Export analytics report">
|
||||
<i class="fas fa-download"></i> Export Report
|
||||
</button>
|
||||
<button id="toggle-analytics" class="analytics-btn" title="Toggle analytics panel">
|
||||
<i class="fas fa-eye"></i> Toggle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-content">
|
||||
<!-- Overview Statistics -->
|
||||
<div class="stats-overview">
|
||||
<div class="stat-card total-persons">
|
||||
<div class="stat-icon"><i class="fas fa-users"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number" id="total-persons">0</div>
|
||||
<div class="stat-label">Total Persons</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card male-count">
|
||||
<div class="stat-icon"><i class="fas fa-male"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number" id="male-count">0</div>
|
||||
<div class="stat-label">Male</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card female-count">
|
||||
<div class="stat-icon"><i class="fas fa-female"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number" id="female-count">0</div>
|
||||
<div class="stat-label">Female</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card generations-count">
|
||||
<div class="stat-icon"><i class="fas fa-layer-group"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number" id="generations-count">0</div>
|
||||
<div class="stat-label">Generations</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card families-count">
|
||||
<div class="stat-icon"><i class="fas fa-home"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number" id="families-count">0</div>
|
||||
<div class="stat-label">Families</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card avg-children">
|
||||
<div class="stat-icon"><i class="fas fa-baby"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number" id="avg-children">0</div>
|
||||
<div class="stat-label">Avg Children</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Tabs -->
|
||||
<div class="chart-tabs">
|
||||
<button class="tab-btn active" data-tab="demographic">Demographics</button>
|
||||
<button class="tab-btn" data-tab="generational">Generations</button>
|
||||
<button class="tab-btn" data-tab="relationships">Relationships</button>
|
||||
<button class="tab-btn" data-tab="timeline">Timeline</button>
|
||||
<button class="tab-btn" data-tab="longevity">Longevity</button>
|
||||
</div>
|
||||
|
||||
<!-- Chart Panels -->
|
||||
<div class="chart-panels">
|
||||
<!-- Demographics Panel -->
|
||||
<div class="chart-panel active" id="demographic-panel">
|
||||
<div class="panel-header">
|
||||
<h4>Demographic Analysis</h4>
|
||||
<div class="chart-options">
|
||||
<select id="demographic-chart-type">
|
||||
<option value="pie">Pie Chart</option>
|
||||
<option value="bar">Bar Chart</option>
|
||||
<option value="doughnut">Doughnut Chart</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="demographic-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-insights">
|
||||
<div class="insight-item">
|
||||
<strong>Gender Ratio:</strong> <span id="gender-ratio">Loading...</span>
|
||||
</div>
|
||||
<div class="insight-item">
|
||||
<strong>Most Common Names:</strong> <span id="common-names">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generational Panel -->
|
||||
<div class="chart-panel" id="generational-panel">
|
||||
<div class="panel-header">
|
||||
<h4>Generational Distribution</h4>
|
||||
<div class="chart-options">
|
||||
<select id="generational-metric">
|
||||
<option value="count">Person Count</option>
|
||||
<option value="lifespan">Average Lifespan</option>
|
||||
<option value="children">Children per Generation</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="generational-chart"></canvas>
|
||||
</div>
|
||||
<div class="generation-details">
|
||||
<div id="generation-breakdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relationships Panel -->
|
||||
<div class="chart-panel" id="relationships-panel">
|
||||
<div class="panel-header">
|
||||
<h4>Family Relationships</h4>
|
||||
</div>
|
||||
<div class="relationship-metrics">
|
||||
<div class="metric-grid">
|
||||
<div class="metric-item">
|
||||
<div class="metric-value" id="married-couples">0</div>
|
||||
<div class="metric-label">Married Couples</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-value" id="single-parents">0</div>
|
||||
<div class="metric-label">Single Parents</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-value" id="childless-couples">0</div>
|
||||
<div class="metric-label">Childless Couples</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-value" id="largest-family">0</div>
|
||||
<div class="metric-label">Largest Family</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="relationships-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Panel -->
|
||||
<div class="chart-panel" id="timeline-panel">
|
||||
<div class="panel-header">
|
||||
<h4>Biblical Timeline Analysis</h4>
|
||||
<div class="chart-options">
|
||||
<select id="timeline-view">
|
||||
<option value="births">Birth Timeline</option>
|
||||
<option value="lifespans">Lifespan Overview</option>
|
||||
<option value="generations">Generation Overlap</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container timeline-container">
|
||||
<canvas id="timeline-chart"></canvas>
|
||||
</div>
|
||||
<div class="timeline-insights">
|
||||
<div class="insight-grid">
|
||||
<div class="insight-card">
|
||||
<h5>Longest Lifespan</h5>
|
||||
<div id="longest-lived">Loading...</div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h5>Shortest Lifespan</h5>
|
||||
<div id="shortest-lived">Loading...</div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h5>Average Lifespan</h5>
|
||||
<div id="average-lifespan">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Longevity Panel -->
|
||||
<div class="chart-panel" id="longevity-panel">
|
||||
<div class="panel-header">
|
||||
<h4>Longevity Analysis</h4>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="longevity-chart"></canvas>
|
||||
</div>
|
||||
<div class="longevity-trends">
|
||||
<div id="longevity-trends-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Insights Section -->
|
||||
<div class="detailed-insights">
|
||||
<h4>Detailed Insights</h4>
|
||||
<div class="insights-grid">
|
||||
<div class="insight-section">
|
||||
<h5>Family Patterns</h5>
|
||||
<ul id="family-patterns"></ul>
|
||||
</div>
|
||||
<div class="insight-section">
|
||||
<h5>Notable Statistics</h5>
|
||||
<ul id="notable-stats"></ul>
|
||||
</div>
|
||||
<div class="insight-section">
|
||||
<h5>Genealogical Insights</h5>
|
||||
<ul id="genealogical-insights"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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('
|
||||
@@ -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 = `
|
||||
<div class="search-bar-container">
|
||||
<div class="search-input-wrapper">
|
||||
<input type="text" id="family-search-input" class="search-input"
|
||||
placeholder="Search by name, title, or relationship...">
|
||||
<button id="search-clear-btn" class="search-clear-btn" title="Clear search">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<button id="advanced-search-toggle" class="advanced-search-toggle" title="Advanced search options">
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-filters">
|
||||
<div class="filter-group">
|
||||
<label>Search in:</label>
|
||||
<label><input type="checkbox" name="searchFields" value="name" checked> Names</label>
|
||||
<label><input type="checkbox" name="searchFields" value="title" checked> Titles</label>
|
||||
<label><input type="checkbox" name="searchFields" value="description"> Descriptions</label>
|
||||
<label><input type="checkbox" name="searchFields" value="verses"> Scripture References</label>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Gender:</label>
|
||||
<label><input type="radio" name="genderFilter" value="all" checked> All</label>
|
||||
<label><input type="radio" name="genderFilter" value="male"> Male</label>
|
||||
<label><input type="radio" name="genderFilter" value="female"> Female</label>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Generation:</label>
|
||||
<select id="generation-filter">
|
||||
<option value="all">All Generations</option>
|
||||
<option value="ancestors">Ancestors Only</option>
|
||||
<option value="descendants">Descendants Only</option>
|
||||
<option value="same">Same Generation</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-results-container">
|
||||
<div class="search-results-header">
|
||||
<span class="results-count">0 results</span>
|
||||
<div class="result-actions">
|
||||
<button id="highlight-all-btn" class="action-btn" title="Highlight all results in tree">
|
||||
<i class="fas fa-highlighter"></i> Highlight All
|
||||
</button>
|
||||
<button id="export-results-btn" class="action-btn" title="Export search results">
|
||||
<i class="fas fa-download"></i> Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="search-results-list" class="search-results-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navigation-container">
|
||||
<div class="breadcrumb-navigation">
|
||||
<div class="breadcrumb-header">
|
||||
<h4>Navigation Path</h4>
|
||||
<button id="clear-breadcrumbs" class="clear-btn" title="Clear navigation history">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="breadcrumb-trail" class="breadcrumb-trail"></div>
|
||||
</div>
|
||||
|
||||
<div class="quick-navigation">
|
||||
<div class="nav-section">
|
||||
<h4>Quick Access</h4>
|
||||
<div class="quick-nav-buttons">
|
||||
<button id="nav-root" class="nav-btn" title="Go to tree root">
|
||||
<i class="fas fa-home"></i> Root
|
||||
</button>
|
||||
<button id="nav-back" class="nav-btn" title="Go back">
|
||||
<i class="fas fa-arrow-left"></i> Back
|
||||
</button>
|
||||
<button id="nav-forward" class="nav-btn" title="Go forward">
|
||||
<i class="fas fa-arrow-right"></i> Forward
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4>Bookmarks</h4>
|
||||
<div class="bookmark-controls">
|
||||
<button id="add-bookmark" class="bookmark-btn" title="Bookmark current person">
|
||||
<i class="fas fa-bookmark"></i> Add
|
||||
</button>
|
||||
<button id="manage-bookmarks" class="bookmark-btn" title="Manage bookmarks">
|
||||
<i class="fas fa-cog"></i> Manage
|
||||
</button>
|
||||
</div>
|
||||
<div id="bookmarks-list" class="bookmarks-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = '<div class="no-results">No results found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchResults.forEach((result, index) => {
|
||||
const person = this.familyData[result.id];
|
||||
const resultElement = document.createElement('div');
|
||||
resultElement.className = 'search-result-item';
|
||||
resultElement.innerHTML = `
|
||||
<div class="result-info">
|
||||
<div class="result-name">${person.name}</div>
|
||||
<div class="result-title">${person.title || 'Biblical Figure'}</div>
|
||||
<div class="result-snippet">${this.createSearchSnippet(person)}</div>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button class="select-person-btn" data-person-id="${result.id}" title="View this person">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="highlight-person-btn" data-person-id="${result.id}" title="Highlight in tree">
|
||||
<i class="fas fa-highlighter"></i>
|
||||
</button>
|
||||
<button class="bookmark-person-btn" data-person-id="${result.id}" title="Bookmark this person">
|
||||
<i class="fas fa-bookmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<span class="breadcrumb-name">${crumb.name}</span>
|
||||
<button class="breadcrumb-select" data-person-id="${crumb.id}" title="Go to ${crumb.name}">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
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 = '<div class="no-bookmarks">No bookmarks yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarks.forEach(bookmark => {
|
||||
const bookmarkElement = document.createElement('div');
|
||||
bookmarkElement.className = 'bookmark-item';
|
||||
bookmarkElement.innerHTML = `
|
||||
<div class="bookmark-info">
|
||||
<div class="bookmark-name">${bookmark.name}</div>
|
||||
<div class="bookmark-title">${bookmark.title}</div>
|
||||
</div>
|
||||
<div class="bookmark-actions">
|
||||
<button class="bookmark-select" data-person-id="${bookmark.id}" title="Go to ${bookmark.name}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="bookmark-remove" data-person-id="${bookmark.id}" title="Remove bookmark">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<style>
|
||||
.family-search-container {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-bar-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 10px 15px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 25px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.search-clear-btn, .advanced-search-toggle {
|
||||
margin-left: 10px;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover, .advanced-search-toggle:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: none;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-filters.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
margin-right: 15px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-results-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.search-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.search-results-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 13px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.result-actions button {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-actions button:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.navigation-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.breadcrumb-navigation, .quick-navigation {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.breadcrumb-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.breadcrumb-trail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-section h4 {
|
||||
margin-bottom: 10px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.quick-nav-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.nav-btn, .bookmark-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dee2e6;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav-btn:hover, .bookmark-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.bookmarks-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
}
|
||||
|
||||
.bookmark-name {
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bookmark-title {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.search-notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navigation-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breadcrumb-trail {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
document.head.insertAdjacentHTML('beforeend', searchStyles);
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = FamilyTreeSearch;
|
||||
}
|
||||
Reference in New Issue
Block a user