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:
2025-05-30 19:46:13 -04:00
parent 221344a085
commit 7c92e5ce18
3 changed files with 2321 additions and 0 deletions
@@ -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;
}