<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GEDminer - GEDCOM Analysis Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #f8fafc; /* slate-50 */
}
.table-container {
height: 55vh;
overflow-y: scroll;
}
.tab-content, .research-tab-content {
display: none;
}
.tab-content.active, .research-tab-content.active {
display: block;
}
.tab-button, .research-tab-button {
padding: 0.5rem 1rem;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
color: #475569; /* slate-600 */
}
.tab-button:hover, .research-tab-button:hover {
color: #0d9488; /* teal-600 */
}
.tab-button.active, .research-tab-button.active {
border-color: #0d9488; /* teal-600 */
color: #0f172a; /* slate-900 */
font-weight: 600;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: #fefefe;
margin: 10% auto;
padding: 24px;
border: 1px solid #e2e8f0;
width: 90%;
max-width: 600px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
.close-button {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close-button:hover,
.close-button:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.summary-card {
border: 1px solid #e2e8f0; /* slate-200 */
}
.sortable-header {
cursor: pointer;
}
.sortable-header:hover {
background-color: #f1f5f9; /* slate-100 */
}
</style>
</head>
<body class="bg-slate-50 text-slate-800 flex flex-col min-h-screen">
<div class="container mx-auto p-4 sm:p-6 lg:p-8 flex-grow">
<header class="flex items-center justify-between mb-8">
<div class="flex items-center space-x-4">
<div class="bg-teal-600 p-3 rounded-lg shadow-md bg-gradient-to-br from-teal-500 to-cyan-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-slate-900">GEDminer</h1>
<p class="mt-1 text-slate-700">Your intelligent GEDCOM analysis assistant.</p>
</div>
</div>
<div id="settingsContainer" class="relative hidden">
<button id="settingsBtn" class="p-2 rounded-full hover:bg-slate-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<div id="settingsPanel" class="hidden absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-xl p-4 z-10 border">
<p class="text-sm font-medium text-slate-800">Home Person</p>
<p id="homePersonDisplay" class="text-xs text-slate-600 mb-2">Not set</p>
<div id="homePersonSelectWrapper"></div>
</div>
</div>
</header>
<main class="bg-white rounded-xl shadow-md p-6">
<!-- Initial Message -->
<div id="initialMessage" class="text-center text-slate-600 pb-6">
<p class="mt-1">Welcome to your browser based genealogy research assistant, here to give you statistics, tips and suggestions to get you further, faster.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 my-8 text-center">
<div class="feature-highlight bg-slate-50 p-6 rounded-lg border border-slate-200 shadow-sm">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-teal-100 text-teal-600 mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
</div>
<h3 class="mt-4 text-lg font-semibold text-slate-800">Analyse</h3>
<p class="mt-1 text-sm text-slate-600">Instantly visualize your family data.</p>
</div>
<div class="feature-highlight bg-slate-50 p-6 rounded-lg border border-slate-200 shadow-sm">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-teal-100 text-teal-600 mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<h3 class="mt-4 text-lg font-semibold text-slate-800">Validate</h3>
<p class="mt-1 text-sm text-slate-600">Find potential errors and inconsistencies.</p>
</div>
<div class="feature-highlight bg-slate-50 p-6 rounded-lg border border-slate-200 shadow-sm">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-teal-100 text-teal-600 mx-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
</div>
<h3 class="mt-4 text-lg font-semibold text-slate-800">Discover</h3>
<p class="mt-1 text-sm text-slate-600">Get research suggestions and new insights.</p>
</div>
</div>
<p class="mt-4">Ready to analyse your family tree? Upload your GEDCOM file here to begin, or <a href="#" id="sampleLink" class="text-teal-600 hover:underline">try a sample file</a>.</p>
</div>
<!-- File Upload Section -->
<div id="uploadSection" class="mb-4">
<!-- This container will hold either the large dropzone or the compact bar -->
</div>
<div id="privacyNotice" class="hidden text-center text-xs text-slate-500 mb-6 items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Your GEDCOM file is processed entirely in your browser. No data is uploaded or stored.
</div>
<!-- Analysis Output Section -->
<div id="analysisOutput" class="hidden">
<!-- Tabs -->
<div class="border-b border-slate-200 mb-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button class="tab-button active" onclick="openTab(event, 'summary')">Summary</button>
<button class="tab-button" onclick="openTab(event, 'analysis')">Analysis</button>
<button class="tab-button" onclick="openTab(event, 'validation')">Validation</button>
<button class="tab-button" onclick="openTab(event, 'research')">Research</button>
<button class="tab-button" onclick="openTab(event, 'tools')">Tools</button>
</nav>
</div>
<!-- Summary Tab -->
<div id="summary" class="tab-content active">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6 text-center">
<div class="bg-slate-100 p-4 rounded-lg summary-card"><h3 class="text-sm font-medium text-slate-600">Individuals</h3><p class="text-3xl font-bold text-teal-600" id="totalIndividuals">0</p></div>
<div class="bg-slate-100 p-4 rounded-lg summary-card"><h3 class="text-sm font-medium text-slate-600">Families</h3><p class="text-3xl font-bold text-teal-600" id="totalFamilies">0</p></div>
<div class="bg-slate-100 p-4 rounded-lg summary-card"><h3 class="text-sm font-medium text-slate-600">Surnames</h3><p class="text-3xl font-bold text-teal-600" id="totalSurnames">0</p></div>
<div class="bg-slate-100 p-4 rounded-lg summary-card"><h3 class="text-sm font-medium text-slate-600">Avg. Lifespan</h3><p class="text-3xl font-bold text-teal-600" id="avgLifespan">0</p></div>
<div class="bg-slate-100 p-4 rounded-lg summary-card"><h3 class="text-sm font-medium text-slate-600">Migrants</h3><p class="text-3xl font-bold text-teal-600" id="totalMigrants">0</p></div>
</div>
<div>
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold text-slate-800">Individuals <span id="individualsCount" class="text-base font-normal text-slate-700"></span></h2>
<input type="text" id="searchFilter" placeholder="Filter by name..." class="w-1/3 p-2 border border-slate-300 rounded-md focus:ring-teal-500 focus:border-teal-500">
</div>
<div class="table-container rounded-lg border border-slate-200">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50 sticky top-0">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase tracking-wider sortable-header" onclick="sortTable(0)">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase tracking-wider sortable-header" onclick="sortTable(1)">Sex</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase tracking-wider sortable-header" onclick="sortTable(2)">Birth Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase tracking-wider sortable-header" onclick="sortTable(3)">Death Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="individualsTableBody" class="bg-white divide-y divide-slate-200"></tbody>
</table>
</div>
</div>
</div>
<!-- Analysis Tab -->
<div id="analysis" class="tab-content">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 class="text-xl font-semibold mb-4 text-slate-800">Gender Distribution</h2>
<div class="p-4 border rounded-lg h-96 border-slate-200"><canvas id="genderDistributionChart"></canvas></div>
</div>
<div>
<h2 class="text-xl font-semibold mb-4 text-slate-800">Age at Death</h2>
<div class="p-4 border rounded-lg h-96 border-slate-200"><canvas id="ageAtDeathChart"></canvas></div>
</div>
<div>
<h2 class="text-xl font-semibold mb-4 text-slate-800">Top 10 Surnames</h2>
<div class="p-4 border rounded-lg h-96 border-slate-200"><canvas id="surnameChart"></canvas></div>
</div>
<div>
<h2 class="text-xl font-semibold mb-4 text-slate-800">Surnames (Ranks 11-25)</h2>
<div id="surnameTableContainer" class="p-4 border rounded-lg h-96 overflow-y-auto border-slate-200"></div>
</div>
<div>
<h2 class="text-xl font-semibold mb-4 text-slate-800">Top 10 Locations</h2>
<div class="p-4 border rounded-lg h-96 border-slate-200"><canvas id="locationsChart"></canvas></div>
</div>
<div>
<h2 class="text-xl font-semibold mb-4 text-slate-800">Locations (Ranks 11-25)</h2>
<div id="locationsTableContainer" class="p-4 border rounded-lg h-96 overflow-y-auto border-slate-200"></div>
</div>
</div>
<div class="mt-6">
<h2 class="text-xl font-semibold mb-4 text-slate-800">Births by Decade</h2>
<div class="p-4 border rounded-lg h-80 border-slate-200"><canvas id="birthsByDecadeChart"></canvas></div>
</div>
</div>
<!-- Data Tab -->
<div id="validation" class="tab-content">
<h2 class="text-xl font-semibold mb-4 text-slate-800">Data Validation</h2>
<div id="validationResults" class="space-y-4"></div>
</div>
<!-- Research Tab -->
<div id="research" class="tab-content">
<h2 class="text-xl font-semibold mb-4 text-slate-800">Research Suggestions</h2>
<div id="researchSuggestions">
<!-- This will be populated by JS -->
</div>
</div>
<!-- Tools Tab -->
<div id="tools" class="tab-content">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 class="text-xl font-semibold mb-4 text-slate-800">Relationship Finder</h2>
<div class="p-4 border rounded-lg bg-slate-50">
<div class="grid grid-cols-2 gap-4 items-end">
<div id="person1Wrapper"></div>
<div id="person2Wrapper"></div>
</div>
<button id="findRelationshipBtn" class="mt-4 bg-teal-600 text-white px-4 py-2 rounded-md hover:bg-teal-700 text-sm font-medium">Find Relationship</button>
<div id="relationshipResult" class="mt-4 text-lg font-semibold text-slate-800"></div>
</div>
</div>
<div>
<h2 id="onThisDayTitle" class="text-xl font-semibold mb-4 text-slate-800">On This Day</h2>
<div id="onThisDayResult" class="p-4 border rounded-lg bg-slate-50 h-full"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<footer class="text-center p-4 text-sm text-slate-600">
<p>© <span id="copyright-year"></span> <a href="mailto:mykoclelland@gmail.com" class="text-teal-600 hover:underline">Myko Clelland</a></p>
</footer>
<!-- Modal for Individual Facts -->
<div id="factsModal" class="modal">
<div class="modal-content">
<span class="close-button" onclick="closeModal()">×</span>
<h2 id="modalName" class="text-2xl font-bold mb-4 text-slate-900"></h2>
<div id="modalFacts" class="max-h-96 overflow-y-auto"></div>
</div>
</div>
<script>
document.getElementById('copyright-year').textContent = '2025';
let allIndividuals = [];
let allFamilies = [];
let homePersonId = null;
let sortState = { table: 'individuals', column: 0, direction: 'asc' };
const TAG_MAP = {
'BIRT': 'Birth', 'CHR': 'Christening', 'DEAT': 'Death', 'BURI': 'Burial', 'CREM': 'Cremation',
'ADOP': 'Adoption', 'BAPM': 'Baptism', 'BARM': 'Bar Mitzvah', 'BASM': 'Bas Mitzvah', 'BLES': 'Blessing',
'CHRA': 'Adult Christening', 'CONF': 'Confirmation', 'FCOM': 'First Communion', 'ORDN': 'Ordination',
'NATU': 'Naturalization', 'EMIG': 'Emigration', 'IMMI': 'Immigration', 'CENS': 'Census',
'PROB': 'Probate', 'WILL': 'Will', 'GRAD': 'Graduation', 'RETI': 'Retirement', 'CAST': 'Caste',
'DSCR': 'Physical Description', 'EDUC': 'Education', 'IDNO': 'Identity Number', 'NATI': 'Nationality',
'NCHI': 'Children Count', 'NMR': 'Marriage Count', 'OCCU': 'Occupation', 'PROP': 'Property',
'RELI': 'Religion', 'RESI': 'Residence', 'SSN': 'Social Security Number', 'TITL': 'Title',
'ANUL': 'Annulment', 'DIV': 'Divorce', 'DIVF': 'Divorce Filed', 'ENGA': 'Engagement',
'MARB': 'Marriage Banns', 'MARC': 'Marriage Contract', 'MARR': 'Marriage', 'MARL': 'Marriage License',
'MARS': 'Marriage Settlement'
};
const MONTH_MAP = {JAN:0,FEB:1,MAR:2,APR:3,MAY:4,JUN:5,JUL:6,AUG:7,SEP:8,OCT:9,NOV:10,DEC:11};
// --- TEMPLATES for dynamic UI parts ---
const uploadContainerTemplate = `
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-slate-300 border-dashed rounded-md bg-slate-50">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-slate-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true"><path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4V12a4 4 0 014-4h12l4 4h12a4 4 0 014 4z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
<div class="flex text-sm text-slate-600">
<label for="gedcomFile" class="relative cursor-pointer bg-white rounded-md font-medium text-teal-600 hover:text-teal-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-teal-500">
<span>Upload a file</span>
<input id="gedcomFile" name="gedcomFile" type="file" class="sr-only" accept=".ged">
</label>
<p class="pl-1">or drag and drop</p>
</div>
</div>
</div>`;
const replaceContainerTemplate = `
<div class="flex items-center justify-between p-3 border rounded-md bg-slate-50">
<p class="text-sm font-medium text-slate-700" id="fileNameDisplay"></p>
<button id="replaceGedcomBtn" class="bg-teal-600 text-white px-4 py-2 rounded-md hover:bg-teal-700 text-sm font-medium">Replace File</button>
</div>`;
// --- INITIAL UI SETUP ---
const uploadSection = document.getElementById('uploadSection');
uploadSection.innerHTML = uploadContainerTemplate;
uploadSection.querySelector('#gedcomFile').addEventListener('change', handleFileSelect);
document.getElementById('settingsBtn').addEventListener('click', () => {
document.getElementById('settingsPanel').classList.toggle('hidden');
});
document.getElementById('findRelationshipBtn').addEventListener('click', findAndDisplayRelationship);
document.getElementById('searchFilter').addEventListener('keyup', filterIndividuals);
document.getElementById('sampleLink').addEventListener('click', loadSampleData);
function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const { individuals, families } = parseGedcom(e.target.result);
initializeAppData(individuals, families, file.name);
} catch (error) {
console.error("Error parsing GEDCOM file:", error);
alert("There was an error parsing your GEDCOM file.");
}
};
reader.readAsText(file);
}
function loadSampleData(event) {
event.preventDefault();
const sampleGedcom = `0 HEAD
1 SOUR GEDminer Sample
${Array.from({ length: 260 }, (_, i) => {
const firstNamesM = ["James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph", "Thomas", "Charles", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "George"];
const firstNamesF = ["Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Barbara", "Susan", "Jessica", "Sarah", "Karen", "Nancy", "Margaret", "Lisa", "Betty", "Dorothy", "Sandra"];
const lastNames = ["Campbell", "Smith", "Stewart", "Wilson", "Jones", "Robertson", "Thompson", "Anderson", "MacDonald", "Scott", "Reid", "Murray", "Taylor", "Clark", "Wright", "Walker", "Martin", "Hall", "White", "King", "Green", "Baker", "Adams", "Nelson", "Carter", "Mitchell", "Perez", "Roberts", "Turner"];
const places = ["Glasgow, Scotland", "Edinburgh, Scotland", "London, England", "Dublin, Ireland", "New York, USA", "Boston, USA", "Cardiff, Wales", "Belfast, Northern Ireland", "Manchester, England", "Liverpool, England", "Toronto, Canada", "Sydney, Australia"];
const sex = Math.random() > 0.48 ? 'F' : 'M'; // Skewed towards female
const firstName = sex === 'M' ? firstNamesM[i % firstNamesM.length] : firstNamesF[i % firstNamesF.length];
let lastName;
if (i < 50) lastName = "Campbell";
else if (i < 90) lastName = "Smith";
else if (i < 120) lastName = "Stewart";
else if (i < 140) lastName = "Wilson";
else if (i < 155) lastName = "Jones";
else if (i < 170) lastName = "Robertson";
else if (i < 180) lastName = "Thompson";
else if (i < 190) lastName = "Anderson";
else if (i < 200) lastName = "MacDonald";
else if (i < 210) lastName = "Scott";
else if (i < 218) lastName = "Reid";
else if (i < 226) lastName = "Murray";
else if (i < 233) lastName = "Taylor";
else if (i < 240) lastName = "Clark";
else lastName = lastNames[i % lastNames.length];
const birthYear = 1750 + Math.floor(Math.random() * 150);
const deathYear = birthYear + 20 + Math.floor(Math.random() * 70);
let famc = '';
if (i > 1) {
famc = `1 FAMC @F${Math.floor((i-1) / 2) + 1}@`;
}
let fams = '';
if (i < 130 && i % 5 !== 0) { // Some people don't marry
fams = `1 FAMS @F${i + 1}@`;
}
return `0 @I${i + 1}@ INDI
1 NAME ${firstName} /${lastName}/
1 SEX ${sex}
1 BIRT
2 DATE ${Math.floor(Math.random() * 28) + 1} JAN ${birthYear}
2 PLAC ${places[i % places.length]}
${i < 200 ? `1 DEAT
2 DATE ${Math.floor(Math.random() * 28) + 1} MAR ${deathYear}
2 PLAC ${places[(i+3) % places.length]}` : ''}
${famc}
${fams}
1 SOUR
2 TITL Sample Data`;
}).join('\n')}
${Array.from({ length: 130 }, (_, i) => {
const marrYear = 1770 + Math.floor(Math.random() * 150);
const husbandIndex = i * 2 + 1;
const wifeIndex = i * 2 + 2;
if (husbandIndex > 260 || wifeIndex > 260) return '';
const child1Index = husbandIndex + 2;
const child2Index = husbandIndex + 3;
let marrTag = '';
if (i % 4 !== 0) { // Some families are unmarried
marrTag = `1 MARR
2 DATE ${marrYear}`;
}
return `0 @F${i + 1}@ FAM
1 HUSB @I${husbandIndex}@
1 WIFE @I${wifeIndex}@
${child1Index <= 260 ? `1 CHIL @I${child1Index}@` : ''}
${child2Index <= 260 ? `1 CHIL @I${child2Index}@` : ''}
${marrTag}`;
}).join('\n')}`;
const { individuals, families } = parseGedcom(sampleGedcom);
initializeAppData(individuals, families, "sample.ged");
}
function initializeAppData(individuals, families, fileName) {
uploadSection.innerHTML = replaceContainerTemplate;
document.getElementById('fileNameDisplay').textContent = fileName;
document.getElementById('replaceGedcomBtn').addEventListener('click', () => {
uploadSection.innerHTML = uploadContainerTemplate;
uploadSection.querySelector('#gedcomFile').addEventListener('change', handleFileSelect);
});
document.getElementById('initialMessage').classList.add('hidden');
document.getElementById('privacyNotice').style.display = 'flex';
document.getElementById('analysisOutput').classList.remove('hidden');
document.getElementById('settingsContainer').classList.remove('hidden');
allIndividuals = individuals;
allFamilies = families;
if (allIndividuals.length > 0) {
homePersonId = allIndividuals[0].id;
}
displayAnalysis(individuals, families);
populateSelectDropdowns();
updateHomePersonDisplay();
findEventsOnThisDay();
}
function parseGedcom(content) {
const lines = content.split(/\r\n|\n/).map(line => {
const parts = line.trim().split(' ');
return { level: parseInt(parts[0], 10), tag: parts[1] || '', value: parts.slice(2).join(' ') };
}).filter(l => !isNaN(l.level));
const individuals = [], families = [];
for (let i = 0; i < lines.length; ) {
const block = getRecordBlock(lines, i);
if (lines[i].value.includes('INDI')) individuals.push(processIndividual(block));
else if (lines[i].value.includes('FAM')) families.push(processFamily(block));
i += block.length || 1;
}
return { individuals, families };
}
function getRecordBlock(lines, startIndex) {
const block = [lines[startIndex]];
let i = startIndex + 1;
while (i < lines.length && lines[i].level !== 0) {
block.push(lines[i]);
i++;
}
return block;
}
function processRecord(block) {
const record = { id: block[0].tag, events: [], famc: null, fams: [], husband: null, wife: null, children: [] };
for (let i = 1; i < block.length; i++) {
const line = block[i];
if (line.level !== 1) continue;
switch (line.tag) {
case 'NAME':
const nameMatch = line.value.match(/(.*?)\s\/(.*?)\//);
if (nameMatch && nameMatch.length === 3) {
record.name = `${nameMatch[2].trim()}, ${nameMatch[1].trim()}`;
} else {
record.name = line.value.replace(/\//g, '').trim();
}
break;
case 'SEX': record.sex = line.value; break;
case 'FAMC': record.famc = line.value; break;
case 'FAMS': record.fams.push(line.value); break;
case 'HUSB': record.husband = line.value; break;
case 'WIFE': record.wife = line.value; break;
case 'CHIL': record.children.push(line.value); break;
}
if (TAG_MAP[line.tag]) {
const event = { type: line.tag, value: line.value, date: null, place: null, sources: [] };
for (let j = i + 1; j < block.length && block[j].level > line.level; j++) {
if (block[j].level === line.level + 1) {
if (block[j].tag === 'DATE') event.date = block[j].value;
if (block[j].tag === 'PLAC') event.place = block[j].value;
if (block[j].tag === 'SOUR') event.sources.push(block[j].value);
}
}
record.events.push(event);
}
}
return record;
}
function processIndividual(block) {
const record = processRecord(block);
return {
id: record.id, name: record.name || 'Unknown', sex: record.sex || 'U',
birth: record.events.find(e => e.type === 'BIRT')?.date || null,
death: record.events.find(e => e.type === 'DEAT')?.date || null,
events: record.events, famc: record.famc, fams: record.fams
};
}
function processFamily(block) { return processRecord(block); }
function displayAnalysis(individuals, families) {
document.getElementById('totalIndividuals').textContent = individuals.length;
document.getElementById('totalFamilies').textContent = families.length;
const surnames = individuals.map(ind => (ind.name.split(',')[0]).trim()).filter(Boolean);
document.getElementById('totalSurnames').textContent = new Set(surnames).size;
document.getElementById('individualsCount').textContent = `(${individuals.length})`;
renderIndividualsTable(individuals);
calculateAndDisplayMetrics(individuals, families);
runValidation(individuals, families);
generateResearchSuggestions(individuals);
}
function renderIndividualsTable(individuals) {
const tableBody = document.getElementById('individualsTableBody');
tableBody.innerHTML = '';
individuals.forEach(ind => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="px-6 py-4 whitespace-nowrap"><div class="text-sm font-medium text-slate-900">${ind.name}</div></td>
<td class="px-6 py-4 whitespace-nowrap"><div class="text-sm text-slate-800">${ind.sex}</div></td>
<td class="px-6 py-4 whitespace-nowrap"><div class="text-sm text-slate-800">${formatGedcomDate(ind.birth) || '-'}</div></td>
<td class="px-6 py-4 whitespace-nowrap"><div class="text-sm text-slate-800">${formatGedcomDate(ind.death) || '-'}</div></td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium"><button onclick="showFactsModal('${ind.id}')" class="text-teal-600 hover:text-teal-700">View Facts</button></td>
`;
tableBody.appendChild(row);
});
}
function formatGedcomDate(dateStr) {
if (!dateStr) return null;
const parts = dateStr.split(' ');
if (parts.length > 1 && MONTH_MAP[parts[1].toUpperCase()]) {
parts[1] = parts[1].charAt(0).toUpperCase() + parts[1].slice(1).toLowerCase();
}
return parts.join(' ');
}
function parseGedcomDate(dateStr) {
if (!dateStr) return null;
const cleanDate = dateStr.toUpperCase().replace(/ABT|BEF|AFT|EST|CAL/g, '').trim();
const parts = cleanDate.split(' ');
if (parts.length === 3) {
const day = parseInt(parts[0]);
const month = MONTH_MAP[parts[1]];
const year = parseInt(parts[2]);
if (!isNaN(day) && month !== undefined && !isNaN(year)) return new Date(year, month, day);
} else if (parts.length === 2) {
const month = MONTH_MAP[parts[0]];
const year = parseInt(parts[1]);
if (month !== undefined && !isNaN(year)) return new Date(year, month, 1);
} else if (parts.length === 1 && !isNaN(parseInt(parts[0]))) {
return new Date(parseInt(parts[0]), 0, 1);
}
return null;
}
function getCountry(placeStr) {
if (!placeStr) return null;
return placeStr.split(',').pop().trim();
}
function calculateAndDisplayMetrics(individuals, families) {
let totalAge = 0, peopleWithLifespan = 0, migrants = 0;
let maleCount = 0, femaleCount = 0;
const agesAtDeath = [];
const locationCounts = {};
individuals.forEach(ind => {
const birthYear = getYear(ind.birth);
const deathYear = getYear(ind.death);
if (birthYear && deathYear) {
const age = deathYear - birthYear;
if (age >= 0) {
agesAtDeath.push(age);
totalAge += age;
peopleWithLifespan++;
}
}
const birthPlace = ind.events.find(e => e.type === 'BIRT')?.place;
const deathPlace = ind.events.find(e => e.type === 'DEAT')?.place;
if (birthPlace) locationCounts[birthPlace] = (locationCounts[birthPlace] || 0) + 1;
if (deathPlace) locationCounts[deathPlace] = (locationCounts[deathPlace] || 0) + 1;
const birthCountry = getCountry(birthPlace);
const deathCountry = getCountry(deathPlace);
if (birthCountry && deathCountry && birthCountry.toLowerCase() !== deathCountry.toLowerCase()) migrants++;
if (ind.sex === 'M') maleCount++;
if (ind.sex === 'F') femaleCount++;
});
document.getElementById('avgLifespan').textContent = peopleWithLifespan > 0 ? (totalAge / peopleWithLifespan).toFixed(1) : 'N/A';
document.getElementById('totalMigrants').textContent = migrants;
// Gender Chart
drawChart('genderDistributionChart', 'bar', ['Male', 'Female'], [maleCount, femaleCount], 'Gender Distribution', ['rgba(13, 148, 136, 0.7)', 'rgba(13, 148, 136, 0.7)']);
// Age at Death Chart
const ageBins = Array(11).fill(0); // 0-9, 10-19, ..., 100+
agesAtDeath.forEach(age => {
const binIndex = Math.min(Math.floor(age / 10), 10);
ageBins[binIndex]++;
});
const ageLabels = Array.from({length: 10}, (_, i) => `${i*10}-${i*10+9}`);
ageLabels.push('100+');
drawChart('ageAtDeathChart', 'bar', ageLabels, ageBins, 'Age at Death');
const surnameCounts = individuals.reduce((acc, ind) => {
const surname = (ind.name.split(',')[0]).trim();
if (surname) acc[surname] = (acc[surname] || 0) + 1;
return acc;
}, {});
const sortedSurnames = Object.entries(surnameCounts).sort((a, b) => b[1] - a[1]);
drawChart('surnameChart', 'bar', sortedSurnames.slice(0, 10).map(d => d[0]), sortedSurnames.slice(0, 10).map(d => d[1]), 'Surname Count');
displayFrequencyTable('surnameTableContainer', sortedSurnames.slice(10, 25));
const sortedLocations = Object.entries(locationCounts).sort((a, b) => b[1] - a[1]);
drawChart('locationsChart', 'bar', sortedLocations.slice(0, 10).map(d => d[0]), sortedLocations.slice(0, 10).map(d => d[1]), 'Location Count', null, true);
displayFrequencyTable('locationsTableContainer', sortedLocations.slice(10, 25), 'Location');
const birthsByDecade = individuals.reduce((acc, ind) => {
const birthYear = getYear(ind.birth);
if (birthYear) acc[Math.floor(birthYear / 10) * 10] = (acc[Math.floor(birthYear / 10) * 10] || 0) + 1;
return acc;
}, {});
const sortedDecades = Object.entries(birthsByDecade).sort((a,b) => parseInt(a[0]) - parseInt(b[0]));
drawChart('birthsByDecadeChart', 'line', sortedDecades.map(d => d[0] + 's'), sortedDecades.map(d => d[1]), 'Number of Births');
}
function displayFrequencyTable(containerId, data, name = "Surname") {
const container = document.getElementById(containerId);
if (data.length === 0) {
container.innerHTML = `<p class="text-center text-slate-500">No additional ${name.toLowerCase()}s to display.</p>`;
return;
}
let tableHTML = `<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50"><tr>
<th class="px-4 py-2 text-left text-xs font-medium text-slate-700 uppercase">Rank</th>
<th class="px-4 py-2 text-left text-xs font-medium text-slate-700 uppercase">${name}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-slate-700 uppercase">Count</th>
</tr></thead><tbody class="bg-white divide-y divide-slate-200">`;
data.forEach((item, index) => {
tableHTML += `<tr>
<td class="px-4 py-2 text-sm text-slate-700">${index + 11}</td>
<td class="px-4 py-2 text-sm font-medium text-slate-800">${item[0]}</td>
<td class="px-4 py-2 text-sm text-slate-700">${item[1]}</td>
</tr>`;
});
tableHTML += '</tbody></table>';
container.innerHTML = tableHTML;
}
function getYear(d) { return parseGedcomDate(d)?.getFullYear() || null; }
function runValidation(individuals, families) {
const errors = [];
const individualsMap = new Map(individuals.map(i => [i.id, i]));
individuals.forEach(ind => {
const birthDate = parseGedcomDate(ind.birth);
const deathDate = parseGedcomDate(ind.death);
if (birthDate && deathDate && deathDate < birthDate) {
errors.push({ type: 'Died Before Born', name: ind.name, details: `Born: ${formatGedcomDate(ind.birth)}, Died: ${formatGedcomDate(ind.death)}` });
}
});
families.forEach(fam => {
const husband = individualsMap.get(fam.husband);
const wife = individualsMap.get(fam.wife);
const marriageEvent = fam.events.find(e => e.type === 'MARR');
const marriageDateStr = marriageEvent?.date;
const marriageDate = parseGedcomDate(marriageDateStr);
if (husband?.death && marriageDate) {
const deathDate = parseGedcomDate(husband.death);
if (deathDate && marriageDate > deathDate) {
const isAmbiguous = marriageDateStr.trim().split(' ').length < 3 && deathDate.getFullYear() === marriageDate.getFullYear() && deathDate.getMonth() === marriageDate.getMonth();
if (!isAmbiguous) errors.push({ type: 'Marriage After Death', name: husband.name, details: `Died: ${formatGedcomDate(husband.death)}, Married: ${formatGedcomDate(marriageDateStr)}` });
}
}
if (wife?.death && marriageDate) {
const deathDate = parseGedcomDate(wife.death);
if (deathDate && marriageDate > deathDate) {
const isAmbiguous = marriageDateStr.trim().split(' ').length < 3 && deathDate.getFullYear() === marriageDate.getFullYear() && deathDate.getMonth() === marriageDate.getMonth();
if (!isAmbiguous) errors.push({ type: 'Marriage After Death', name: wife.name, details: `Died: ${formatGedcomDate(wife.death)}, Married: ${formatGedcomDate(marriageDateStr)}` });
}
}
fam.children.forEach(childId => {
const child = individualsMap.get(childId);
if (!child?.birth) return;
const childBirthDate = parseGedcomDate(child.birth);
if (!childBirthDate) return;
if (husband?.death) {
const fatherDeathDate = parseGedcomDate(husband.death);
if (fatherDeathDate) {
const conceptionCutoff = new Date(fatherDeathDate);
conceptionCutoff.setMonth(conceptionCutoff.getMonth() + 9);
if (childBirthDate > conceptionCutoff) {
errors.push({ type: 'Born >9 Months After Father\'s Death', name: child.name, details: `Born: ${formatGedcomDate(child.birth)}, Father Died: ${formatGedcomDate(husband.death)}` });
}
}
}
if (wife?.death) {
const motherDeathDate = parseGedcomDate(wife.death);
if (motherDeathDate && childBirthDate > motherDeathDate) {
errors.push({ type: 'Born After Mother\'s Death', name: child.name, details: `Born: ${formatGedcomDate(child.birth)}, Mother Died: ${formatGedcomDate(wife.death)}` });
}
}
});
});
displayValidationResults(errors);
}
function generateResearchSuggestions(individuals) {
const container = document.getElementById('researchSuggestions');
const missingBirths = individuals.filter(ind => !ind.birth);
const missingDeaths = individuals.filter(ind => !ind.death && getYear(ind.birth) < (new Date().getFullYear() - 110));
const missingSources = individuals.filter(ind => ind.events.length > 0 && ind.events.every(e => e.sources.length === 0));
if (missingBirths.length === 0 && missingDeaths.length === 0 && missingSources.length === 0) {
container.innerHTML = '<div class="text-center text-green-600 bg-green-50 p-4 rounded-lg">No obvious research suggestions found. Great work!</div>';
return;
}
container.innerHTML = `
<div class="border-b border-slate-200 mb-4">
<nav class="-mb-px flex space-x-6" aria-label="Research Tabs">
<button class="research-tab-button active" onclick="openResearchTab(event, 'missingBirths')">Missing Births (${missingBirths.length})</button>
<button class="research-tab-button" onclick="openResearchTab(event, 'missingDeaths')">Missing Deaths (${missingDeaths.length})</button>
<button class="research-tab-button" onclick="openResearchTab(event, 'missingSources')">Unsourced People (${missingSources.length})</button>
</nav>
</div>
<p id="researchDescription" class="text-sm text-slate-600 mb-4"></p>
<div id="missingBirths" class="research-tab-content active"></div>
<div id="missingDeaths" class="research-tab-content"></div>
<div id="missingSources" class="research-tab-content"></div>
`;
populateSuggestionTable('missingBirths', missingBirths, { col1: 'Name', col2: 'Death Date' }, (p) => formatGedcomDate(p.death));
populateSuggestionTable('missingDeaths', missingDeaths, { col1: 'Name', col2: 'Birth Date' }, (p) => formatGedcomDate(p.birth));
populateSuggestionTable('missingSources', missingSources, { col1: 'Name', col2: 'Known Dates' }, (p) => `${formatGedcomDate(p.birth) || '????'} - ${formatGedcomDate(p.death) || '????'}`);
document.getElementById('researchDescription').textContent = "This list shows every person in your file who does not have a birth date recorded. Adding a birth date is a key step in building a complete profile.";
}
function populateSuggestionTable(elementId, data, headers, detailFn) {
const container = document.getElementById(elementId);
if(data.length === 0) {
container.innerHTML = '<p class="text-center text-slate-500 py-4">None found.</p>';
return;
}
let tableHTML = `<div class="table-container border rounded-lg"><table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50 sticky top-0"><tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase">${headers.col1}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase">${headers.col2}</th>
</tr></thead><tbody class="bg-white divide-y divide-slate-200">`;
data.forEach(person => {
tableHTML += `<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-slate-900">${person.name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-800">${detailFn(person) || '-'}</td>
</tr>`;
});
tableHTML += '</tbody></table></div>';
container.innerHTML = tableHTML;
}
function displayValidationResults(errors) {
const container = document.getElementById('validationResults');
container.innerHTML = '';
if (errors.length === 0) {
container.innerHTML = '<div class="text-center text-green-600 bg-green-50 p-4 rounded-lg">No validation issues found.</div>';
return;
}
const errorsByType = errors.reduce((acc, err) => {
if (!acc[err.type]) acc[err.type] = [];
acc[err.type].push(err);
return acc;
}, {});
for (const type in errorsByType) {
const section = document.createElement('div');
section.className = 'p-4 border rounded-lg bg-red-50';
let tableHTML = `<h3 class="font-semibold text-lg mb-2 text-red-800">${type} (${errorsByType[type].length})</h3><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase tracking-wider">Name</th><th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase tracking-wider">Details</th></tr></thead><tbody class="bg-white divide-y divide-slate-200"></tbody>`;
errorsByType[type].forEach(err => { tableHTML += `<tr><td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-slate-900">${err.name.replace(/\//g, '')}</td><td class="px-6 py-4 whitespace-nowrap text-sm text-slate-800">${err.details}</td></tr>`; });
tableHTML += '</tbody></table>';
section.innerHTML = tableHTML;
}
}
function filterIndividuals() {
const filter = document.getElementById('searchFilter').value.toLowerCase();
const rows = document.getElementById('individualsTableBody').getElementsByTagName('tr');
let visibleCount = 0;
for (let i = 0; i < rows.length; i++) {
const name = rows[i].getElementsByTagName('td')[0].textContent.toLowerCase();
if (name.includes(filter)) {
rows[i].style.display = '';
visibleCount++;
} else {
rows[i].style.display = 'none';
}
}
const countSpan = document.getElementById('individualsCount');
if(filter) {
countSpan.textContent = `(${visibleCount}/${allIndividuals.length})`;
} else {
countSpan.textContent = `(${allIndividuals.length})`;
}
}
function drawChart(canvasId, type, labels, data, label, colors, isHorizontal = false) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (window[canvasId + 'Instance']) window[canvasId + 'Instance'].destroy();
let chartConfig = {
type: type,
data: {
labels: labels,
datasets: [{
label: label,
data: data,
backgroundColor: colors || 'rgba(13, 148, 136, 0.7)', // teal-600 with opacity
borderColor: colors || '#0f766e', // teal-700
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
};
if (isHorizontal) {
chartConfig.options.indexAxis = 'y';
}
if (type === 'line') {
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(13, 148, 136, 0.4)');
gradient.addColorStop(1, 'rgba(13, 148, 136, 0)');
chartConfig.data.datasets[0].backgroundColor = gradient;
chartConfig.data.datasets[0].borderColor = '#0d9488'; // teal-600
chartConfig.data.datasets[0].fill = true;
chartConfig.data.datasets[0].tension = 0.3;
}
window[canvasId + 'Instance'] = new Chart(ctx, chartConfig);
}
function showFactsModal(id) {
const ind = allIndividuals.find(i => i.id === id);
document.getElementById('modalName').textContent = ind.name;
const factsContainer = document.getElementById('modalFacts');
factsContainer.innerHTML = '';
const table = document.createElement('table');
table.className = 'min-w-full divide-y divide-slate-200';
table.innerHTML = `<thead class="bg-slate-50"><tr><th class="px-4 py-2 text-left text-xs font-medium text-slate-700 uppercase">Fact</th><th class="px-4 py-2 text-left text-xs font-medium text-slate-700 uppercase">Date</th><th class="px-4 py-2 text-left text-xs font-medium text-slate-700 uppercase">Place / Details</th></tr></thead><tbody class="bg-white divide-y divide-slate-200"></tbody>`;
ind.events.forEach(e => table.querySelector('tbody').insertRow().innerHTML = `<td class="px-4 py-2 text-sm font-medium">${TAG_MAP[e.type] || e.type}</td><td class="px-4 py-2 text-sm">${formatGedcomDate(e.date)||'-'}</td><td class="px-4 py-2 text-sm">${e.place||e.value||'-'}</td>`);
factsContainer.appendChild(table);
document.getElementById('factsModal').style.display = 'block';
}
function closeModal() { document.getElementById('factsModal').style.display = 'none'; }
window.onclick = (e) => { if (e.target == document.getElementById('factsModal')) closeModal(); }
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(t => t.style.display = "none");
document.querySelectorAll('.tab-button').forEach(b => b.classList.remove('active'));
document.getElementById(tabName).style.display = "block";
evt.currentTarget.classList.add('active');
}
function openResearchTab(evt, tabName) {
document.querySelectorAll('.research-tab-content').forEach(t => t.style.display = "none");
document.querySelectorAll('.research-tab-button').forEach(b => b.classList.remove('active'));
document.getElementById(tabName).style.display = "block";
evt.currentTarget.classList.add('active');
const researchDescriptions = {
missingBirths: "This list shows every person in your file who does not have a birth date recorded. Adding a birth date is a key step in building a complete profile.",
missingDeaths: "This list shows people born over 110 years ago who do not have a death date. They are likely deceased, and finding their death record could provide new information.",
missingSources: "This list shows people who have facts (like births, marriages, or deaths) recorded, but none of those facts have a source citation. Adding sources is crucial for good genealogy."
};
document.getElementById('researchDescription').textContent = researchDescriptions[tabName];
}
function updateHomePersonDisplay() {
const person = allIndividuals.find(ind => ind.id === homePersonId);
const display = document.getElementById('homePersonDisplay');
if (person) {
display.textContent = `${person.name} (${person.id})`;
} else {
display.textContent = `Not set`;
}
}
function setHomePerson(newId) {
if (allIndividuals.some(ind => ind.id === newId)) {
homePersonId = newId;
updateHomePersonDisplay();
}
}
function populateSelectDropdowns() {
const person1Wrapper = document.getElementById('person1Wrapper');
const person2Wrapper = document.getElementById('person2Wrapper');
const homePersonWrapper = document.getElementById('homePersonSelectWrapper');
createSearchableDropdown(person1Wrapper, 'person1', 'Person 1', allIndividuals);
createSearchableDropdown(person2Wrapper, 'person2', 'Person 2', allIndividuals);
createSearchableDropdown(homePersonWrapper, 'homePersonSelect', 'Home Person', allIndividuals, (id) => {
setHomePerson(id);
document.getElementById('settingsPanel').classList.add('hidden');
});
}
function findAndDisplayRelationship() {
const id1 = document.getElementById('person1Wrapper').dataset.value;
const id2 = document.getElementById('person2Wrapper').dataset.value;
const resultDiv = document.getElementById('relationshipResult');
resultDiv.textContent = calculateRelationship(id1, id2);
}
function getAncestorPath(startId, individualsMap, familiesMap) {
const path = new Map();
const queue = [{ id: startId, level: 0 }];
path.set(startId, 0);
let head = 0;
while(head < queue.length) {
const { id, level } = queue[head++];
const person = individualsMap.get(id);
if (!person || !person.famc) continue;
const family = familiesMap.get(person.famc);
if (!family) continue;
const parents = [family.husband, family.wife].filter(Boolean);
parents.forEach(parentId => {
if (!path.has(parentId)) {
path.set(parentId, level + 1);
queue.push({ id: parentId, level: level + 1 });
}
});
}
return path;
}
function calculateBloodRelationship(id1, id2, individualsMap, familiesMap) {
const path1 = getAncestorPath(id1, individualsMap, familiesMap);
const path2 = getAncestorPath(id2, individualsMap, familiesMap);
let commonAncestor = null;
let level1 = -1, level2 = -1;
let minLevelSum = Infinity;
for (const [ancestorId, l1] of path1.entries()) {
if (path2.has(ancestorId)) {
const l2 = path2.get(ancestorId);
if (l1 + l2 < minLevelSum) {
minLevelSum = l1 + l2;
commonAncestor = ancestorId;
level1 = l1;
level2 = l2;
}
}
}
if (!commonAncestor) return null;
if (level1 === 0) return `Descendant (${level2} generations)`;
if (level2 === 0) return `Ancestor (${level1} generations)`;
const cousinLevel = Math.min(level1, level2) - 1;
const removalLevel = Math.abs(level1 - level2);
if (cousinLevel === 0) return "Sibling";
const cousinOrdinal = cousinLevel === 1 ? '1st' : cousinLevel === 2 ? '2nd' : cousinLevel === 3 ? '3rd' : `${cousinLevel}th`;
if (removalLevel === 0) {
return `${cousinOrdinal} Cousin`;
} else {
return `${cousinOrdinal} Cousin, ${removalLevel}x removed`;
}
}
function calculateRelationship(id1, id2) {
if (!id1 || !id2) return "Please select two people.";
if (id1 === id2) return "Self";
const individualsMap = new Map(allIndividuals.map(i => [i.id, i]));
const familiesMap = new Map(allFamilies.map(f => [f.id, f]));
// 1. Check for direct blood relationship
const bloodRel = calculateBloodRelationship(id1, id2, individualsMap, familiesMap);
if (bloodRel) return bloodRel;
// 2. Check for spousal relationship
const person1 = individualsMap.get(id1);
for (const famsId of person1.fams) {
const family = familiesMap.get(famsId);
if (family && (family.husband === id2 || family.wife === id2)) {
return "Spouse";
}
}
// 3. Check for in-law relationships (spouse of a blood relative)
const person1Spouses = new Set();
person1.fams.forEach(famsId => {
const family = familiesMap.get(famsId);
if(family) {
if(family.husband && family.husband !== id1) person1Spouses.add(family.husband);
if(family.wife && family.wife !== id1) person1Spouses.add(family.wife);
}
});
for (const spouseId of person1Spouses) {
const rel = calculateBloodRelationship(spouseId, id2, individualsMap, familiesMap);
if (rel) {
const spouseName = individualsMap.get(spouseId).name;
return `Spouse of ${spouseName} (${rel})`;
}
}
const person2Spouses = new Set();
const person2 = individualsMap.get(id2);
person2.fams.forEach(famsId => {
const family = familiesMap.get(famsId);
if(family) {
if(family.husband && family.husband !== id2) person2Spouses.add(family.husband);
if(family.wife && family.wife !== id2) person2Spouses.add(family.wife);
}
});
for (const spouseId of person2Spouses) {
const rel = calculateBloodRelationship(id1, spouseId, individualsMap, familiesMap);
if (rel) {
const spouseName = individualsMap.get(spouseId).name;
return `${rel} of ${spouseName} (Spouse)`;
}
}
return "No relationship found.";
}
function findEventsOnThisDay() {
const container = document.getElementById('onThisDayResult');
const today = new Date();
const month = today.getMonth();
const day = today.getDate();
const eventsToday = [];
const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
document.getElementById('onThisDayTitle').textContent = `On This Day (${day} ${monthNames[month]})`;
allIndividuals.forEach(ind => {
ind.events.forEach(event => {
const eventDate = parseGedcomDate(event.date);
if (eventDate && eventDate.getMonth() === month && eventDate.getDate() === day) {
eventsToday.push({ year: eventDate.getFullYear(), name: ind.name, event: TAG_MAP[event.type] || event.type, personId: ind.id });
}
});
});
allFamilies.forEach(fam => {
const marrEvent = fam.events.find(e => e.type === 'MARR');
if (marrEvent && marrEvent.date) {
const eventDate = parseGedcomDate(marrEvent.date);
if (eventDate && eventDate.getMonth() === month && eventDate.getDate() === day) {
const husband = allIndividuals.find(i => i.id === fam.husband)?.name || 'Unknown';
const wife = allIndividuals.find(i => i.id === fam.wife)?.name || 'Unknown';
eventsToday.push({ year: eventDate.getFullYear(), name: `${husband} & ${wife}`, event: 'Marriage', personId: fam.husband || fam.wife });
}
}
});
eventsToday.sort((a,b) => a.year - b.year);
if (eventsToday.length === 0) {
container.innerHTML = `<p class="text-center text-slate-500">No events found for today's date.</p>`;
return;
}
let html = `<div class="table-container border rounded-lg"><table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50 sticky top-0"><tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase">Year</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase">Event</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase">Name(s)</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-700 uppercase">Relationship</th>
</tr></thead><tbody class="bg-white divide-y divide-slate-200">`;
eventsToday.forEach(e => {
const relationship = e.personId ? calculateRelationship(homePersonId, e.personId) : '';
html += `<tr>
<td class="px-6 py-4 text-sm">${e.year}</td>
<td class="px-6 py-4 text-sm font-medium">${e.event}</td>
<td class="px-6 py-4 text-sm">${e.name}</td>
<td class="px-6 py-4 text-sm text-slate-600">${relationship}</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function createSearchableDropdown(wrapper, id, label, individuals, onChangeCallback) {
const birthYear = (p) => getYear(p.birth) || '????';
const deathYear = (p) => getYear(p.death) || '????';
wrapper.innerHTML = `
<label for="${id}-input" class="block text-sm font-medium text-slate-700">${label}</label>
<div class="relative mt-1">
<input type="text" id="${id}-input" class="w-full p-2 border border-slate-300 rounded-md" autocomplete="off">
<div id="${id}-list" class="absolute hidden z-10 w-full bg-white border border-slate-300 rounded-md mt-1 max-h-60 overflow-y-auto"></div>
</div>
`;
const input = wrapper.querySelector(`#${id}-input`);
const list = wrapper.querySelector(`#${id}-list`);
const populateList = (filter = '') => {
list.innerHTML = '';
individuals
.filter(ind => ind.name.toLowerCase().includes(filter.toLowerCase()))
.forEach(ind => {
const item = document.createElement('div');
item.textContent = `${ind.name} (${birthYear(ind)}-${deathYear(ind)})`;
item.dataset.value = ind.id;
item.className = 'p-2 hover:bg-teal-100 cursor-pointer';
item.addEventListener('mousedown', () => {
input.value = item.textContent;
wrapper.dataset.value = ind.id;
list.classList.add('hidden');
if (onChangeCallback) onChangeCallback(ind.id);
});
list.appendChild(item);
});
};
input.addEventListener('focus', () => {
populateList(input.value);
list.classList.remove('hidden');
});
input.addEventListener('keyup', () => populateList(input.value));
input.addEventListener('blur', () => setTimeout(() => list.classList.add('hidden'), 200));
}
function sortTable(columnIndex) {
if (sortState.column === columnIndex) {
sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
} else {
sortState.column = columnIndex;
sortState.direction = 'asc';
}
const sortedIndividuals = [...allIndividuals].sort((a, b) => {
let valA, valB;
switch (columnIndex) {
case 0: // Name
valA = a.name.toLowerCase();
valB = b.name.toLowerCase();
break;
case 1: // Sex
valA = a.sex.toLowerCase();
valB = b.sex.toLowerCase();
break;
case 2: // Birth Date
valA = getYear(a.birth) || 0;
valB = getYear(b.birth) || 0;
break;
case 3: // Death Date
valA = getYear(a.death) || 0;
valB = getYear(b.death) || 0;
break;
}
if (valA < valB) {
return sortState.direction === 'asc' ? -1 : 1;
}
if (valA > valB) {
return sortState.direction === 'asc' ? 1 : -1;
}
return 0;
});
renderIndividualsTable(sortedIndividuals);
updateSortIndicators();
}
function updateSortIndicators() {
const headers = document.querySelectorAll('.sortable-header');
headers.forEach((header, index) => {
header.innerHTML = header.innerHTML.replace(/ (↑|↓)$/, ''); // Remove old arrow
if (index === sortState.column) {
header.innerHTML += sortState.direction === 'asc' ? ' ↑' : ' ↓';
}
});
}
</script>
</body>
</html>