Initial commit - GEDCOM genealogy visualizer

This commit is contained in:
Test User
2026-02-01 13:06:12 +01:00
commit a8f57d0898
4 changed files with 40192 additions and 0 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

903
index.html Normal file
View File

@@ -0,0 +1,903 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visualiseur Généalogie - Geneanet</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
margin: 0 auto;
padding: 0 20px;
}
header {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
header > div:first-child {
flex: 1;
}
.header-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.file-upload {
display: flex;
gap: 10px;
margin-top: 15px;
}
input[type="file"] {
padding: 10px;
border: 2px solid #667eea;
border-radius: 5px;
flex: 1;
}
button {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background 0.3s;
}
button:hover {
background: #764ba2;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
}
.card {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card h2 {
color: #667eea;
margin-bottom: 15px;
font-size: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat-item {
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
border-left: 4px solid #667eea;
}
.stat-label {
color: #666;
font-size: 12px;
text-transform: uppercase;
}
.stat-value {
color: #667eea;
font-size: 24px;
font-weight: bold;
}
.person-list {
max-height: 600px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 5px;
}
.person-item {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.person-item:hover {
background: #f9f9f9;
}
.person-name {
font-weight: bold;
color: #333;
}
.person-info {
font-size: 12px;
color: #999;
margin-top: 5px;
}
.family-tree {
margin-top: 15px;
}
.tree-node {
margin: 10px 0;
padding: 10px;
background: #f9f9f9;
border-left: 3px solid #667eea;
}
.tree-node.selected {
background: #e8eaf6;
border-left-color: #764ba2;
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 5px;
margin-top: 10px;
}
.loading {
text-align: center;
color: #667eea;
}
.tree-selector {
margin-bottom: 15px;
}
select {
padding: 10px;
border: 2px solid #667eea;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
}
.tree-container {
overflow-x: auto;
background: #f9f9f9;
padding: 20px;
border-radius: 5px;
}
.tree-box {
display: inline-block;
border: 2px solid #667eea;
padding: 10px 15px;
margin: 5px;
border-radius: 5px;
background: white;
min-width: 150px;
text-align: center;
}
.tree-box.sosa1 {
border: 3px solid #764ba2;
background: #f3e5f5;
font-weight: bold;
}
.tree-generation {
margin: 20px 0;
padding: 15px;
background: white;
border-left: 4px solid #667eea;
}
.generation-title {
color: #667eea;
font-weight: bold;
margin-bottom: 10px;
font-size: 14px;
}
.generation-people {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.collapsible-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.collapsible-header:hover {
opacity: 0.8;
}
.collapse-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #667eea;
padding: 0;
margin-left: 10px;
}
.collapsible-content {
max-height: 500px;
overflow-y: auto;
transition: max-height 0.3s ease;
}
.collapsible-content.collapsed {
max-height: 0;
overflow: hidden;
}
.search-container {
position: relative;
margin-bottom: 15px;
}
.search-input {
width: 100%;
padding: 10px;
border: 2px solid #667eea;
border-radius: 5px;
font-size: 14px;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 2px solid #667eea;
border-top: none;
max-height: 300px;
overflow-y: auto;
z-index: 10;
display: none;
border-radius: 0 0 5px 5px;
}
.suggestions.active {
display: block;
}
.suggestion-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #eee;
transition: background 0.2s;
}
.suggestion-item:hover {
background: #f3e5f5;
}
.suggestion-item.selected {
background: #e8eaf6;
}
</style>
</head>
<body>
<div class="container">
<div class="container">
<header>
<div>
<h1>📊 Visualiseur Généalogie Geneanet</h1>
<p>Chargez votre fichier GEDCOM pour visualiser votre arbre généalogique</p>
<div class="file-upload">
<input type="file" id="fileInput" accept=".ged,.gedcom" placeholder="Sélectionnez un fichier GEDCOM">
<button onclick="handleFileUpload()">Charger</button>
</div>
<div id="error" class="error" style="display:none;"></div>
</div>
<div class="header-actions">
<button onclick="downloadAsCSV()" id="csvBtn" style="display:none; align-self: flex-start;">📥 Télécharger CSV</button>
</div>
</header>
<div class="content">
<!-- Stats -->
<div class="card">
<h2>📈 Statistiques</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">Personnes</div>
<div class="stat-value" id="statPersonnes">0</div>
</div>
<div class="stat-item">
<div class="stat-label">Familles</div>
<div class="stat-value" id="statFamilles">0</div>
</div>
<div class="stat-item">
<div class="stat-label">Naissances</div>
<div class="stat-value" id="statNaissances">0</div>
</div>
<div class="stat-item">
<div class="stat-label">Décès</div>
<div class="stat-value" id="statDecès">0</div>
</div>
<div class="stat-item">
<div class="stat-label">Mariages</div>
<div class="stat-value" id="statMariages">0</div>
</div>
<div class="stat-item">
<div class="stat-label">Âge moyen</div>
<div class="stat-value" id="statAgesMoyen">-</div>
</div>
</div>
</div>
<!-- Liste des personnes -->
<div class="card">
<h2>👥 Personnes</h2>
<div class="person-list" id="personList"></div>
</div>
</div>
<!-- Chronologie -->
<div class="card" style="margin-top: 20px;">
<div class="collapsible-header" onclick="toggleChronology()">
<h2>📅 Chronologie (Naissances par année)</h2>
<button class="collapse-btn" id="chronologyBtn"></button>
</div>
<div class="collapsible-content" id="chronologyContent" style="height: 300px; position: relative;"></div>
</div>
<!-- Arbre familial -->
<div class="card" style="margin-top: 20px;">
<h2>🌳 Arbre Généalogique (SOSA)</h2>
<div class="tree-selector">
<label>Rechercher SOSA 1 (point de départ):</label>
<div class="search-container">
<input
type="text"
id="personSearch"
class="search-input"
placeholder="Tapez un nom..."
onkeyup="filterSuggestions(this.value)"
onkeydown="handleSearchKeydown(event)"
>
<div class="suggestions" id="suggestions"></div>
</div>
<label style="margin-top: 10px;">Ou sélectionnez dans la liste:</label>
<select id="sosaSelector" onchange="buildFamilyTree()">
<option value="">-- Choisir une personne --</option>
</select>
</div>
<div class="tree-container" id="treeDisplay"></div>
</div>
</div>
<script>
let gedcomData = null;
let individuals = {};
let families = {};
async function handleFileUpload() {
const file = document.getElementById('fileInput').files[0];
if (!file) {
showError('Sélectionnez un fichier d\'abord');
return;
}
try {
const text = await file.text();
gedcomData = parseGedcom(text);
displayData();
clearError();
document.getElementById('csvBtn').style.display = 'block';
} catch (e) {
showError('Erreur lors du parsing du fichier: ' + e.message);
}
}
function parseGedcom(text) {
const lines = text.split('\n');
individuals = {};
families = {};
let currentIndividual = null;
let currentFamily = null;
let lastEventType = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = line.split(' ');
const level = parseInt(parts[0]);
const tag = parts[1];
const value = parts.slice(2).join(' ');
if (level === 0) {
if (tag.startsWith('@I') && tag.endsWith('@')) {
currentIndividual = {
id: tag,
name: '',
sex: '',
birth: { date: null, place: '' },
death: { date: null, place: '' }
};
individuals[tag] = currentIndividual;
currentFamily = null;
lastEventType = null;
} else if (tag.startsWith('@F') && tag.endsWith('@')) {
currentFamily = {
id: tag,
husband: '',
wife: '',
children: []
};
families[tag] = currentFamily;
currentIndividual = null;
lastEventType = null;
}
} else if (level === 1) {
if (currentIndividual) {
if (tag === 'NAME') {
// Format GEDCOM: "Prenom /Nom/"
const nameMatch = value.match(/(.+?)\s*\/(.+?)\//);
if (nameMatch) {
const firstName = nameMatch[1].trim();
const lastName = nameMatch[2].trim();
currentIndividual.name = `${lastName} ${firstName}`;
} else {
currentIndividual.name = value.replace(/\//g, '').trim();
}
} else if (tag === 'SEX') {
currentIndividual.sex = value;
} else if (tag === 'BIRT') {
lastEventType = 'BIRT';
} else if (tag === 'DEAT') {
lastEventType = 'DEAT';
} else if (tag === 'FAMC') {
currentIndividual.familyChild = value;
lastEventType = null;
} else if (tag === 'FAMS') {
currentIndividual.familySpouse = value;
lastEventType = null;
} else {
// Reset pour tous les autres tags (OCCU, RETI, etc)
lastEventType = null;
}
} else if (currentFamily) {
if (tag === 'HUSB') {
currentFamily.husband = value;
} else if (tag === 'WIFE') {
currentFamily.wife = value;
} else if (tag === 'CHIL') {
currentFamily.children.push(value);
}
}
} else if (level === 2 && currentIndividual) {
if (tag === 'DATE') {
const date = parseDate(value);
if (lastEventType === 'BIRT') {
currentIndividual.birth.date = date;
} else if (lastEventType === 'DEAT') {
currentIndividual.death.date = date;
}
} else if (tag === 'PLAC') {
if (lastEventType === 'BIRT') {
currentIndividual.birth.place = value;
} else if (lastEventType === 'DEAT') {
currentIndividual.death.place = value;
}
}
}
}
return { individuals, families };
}
function parseDate(dateStr) {
// Format GEDCOM standard: "jour MOIS année" (ex: "05 NOV 1991" ou "NOV 1991" ou "1991")
const months = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 };
const parts = dateStr.trim().split(/\s+/);
let day = 1, month = 1, year = null;
// Parcourir les parties: nombre, mois (texte), nombre (année)
let numberCount = 0;
for (const part of parts) {
if (months[part]) {
month = months[part];
} else if (!isNaN(part)) {
const num = parseInt(part);
if (numberCount === 0) {
// Premier nombre pourrait être jour OU année si c'est 4 chiffres
if (num > 31) {
year = num;
} else {
day = num;
}
} else {
// Deuxième nombre = année
year = num;
}
numberCount++;
}
}
return year ? new Date(year, month - 1, day) : null;
}
function displayData() {
if (!gedcomData) return;
const personnes = Object.values(individuals).filter(p => p.name);
const familles = Object.keys(families).length;
const naissances = personnes.filter(p => p.birth?.date).length;
const décès = personnes.filter(p => p.death?.date).length;
const mariages = Object.values(families).filter(f => f.husband || f.wife).length;
const ages = personnes
.filter(p => p.birth?.date && p.death?.date)
.map(p => (p.death.date - p.birth.date) / (365.25 * 24 * 60 * 60 * 1000))
.filter(a => a > 0 && a < 150);
const ageMoyen = ages.length > 0 ? Math.round(ages.reduce((a, b) => a + b) / ages.length) : '-';
document.getElementById('statPersonnes').textContent = personnes.length;
document.getElementById('statFamilles').textContent = familles;
document.getElementById('statNaissances').textContent = naissances;
document.getElementById('statDecès').textContent = décès;
document.getElementById('statMariages').textContent = mariages;
document.getElementById('statAgesMoyen').textContent = ageMoyen;
displayPersonList(personnes);
displayChronology(personnes);
populateSosaSelector(personnes);
console.log(`Chargé: ${personnes.length} personnes, ${familles} familles`);
}
function populateSosaSelector(personnes) {
const selector = document.getElementById('sosaSelector');
const sorted = personnes.sort((a, b) => (b.birth?.date || new Date()) - (a.birth?.date || new Date()));
selector.innerHTML = '<option value="">-- Choisir une personne --</option>' +
sorted.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
}
function buildFamilyTree() {
const sosaId = document.getElementById('sosaSelector').value;
if (!sosaId) return;
const sosa1 = individuals[sosaId];
if (!sosa1) return;
const tree = document.getElementById('treeDisplay');
tree.innerHTML = '';
// SOSA 1
tree.innerHTML += `<div class="tree-generation">
<div class="generation-title">SOSA 1 - Sujet</div>
<div class="generation-people">
<div class="tree-box sosa1">${sosa1.name}<br><small>${sosa1.birth?.date?.getFullYear() || '?'}</small></div>
</div>
</div>`;
// Ascendants
const ascendants = getAscendants(sosa1);
if (ascendants.parents.length > 0) {
tree.innerHTML += `<div class="tree-generation">
<div class="generation-title">⬆️ Parents (SOSA 2-3)</div>
<div class="generation-people">
${ascendants.parents.map(p => `<div class="tree-box">${p.name}<br><small>${p.birth?.date?.getFullYear() || '?'} - ${p.death?.date?.getFullYear() || '?'}</small></div>`).join('')}
</div>
</div>`;
}
if (ascendants.grandparents.length > 0) {
tree.innerHTML += `<div class="tree-generation">
<div class="generation-title">⬆️ Grands-parents (SOSA 4-7)</div>
<div class="generation-people">
${ascendants.grandparents.map(p => `<div class="tree-box">${p.name}<br><small>${p.birth?.date?.getFullYear() || '?'}</small></div>`).join('')}
</div>
</div>`;
}
if (ascendants.greatgrandparents.length > 0) {
tree.innerHTML += `<div class="tree-generation">
<div class="generation-title">⬆️ Arrière-grands-parents (SOSA 8-15)</div>
<div class="generation-people">
${ascendants.greatgrandparents.map(p => `<div class="tree-box">${p.name}<br><small>${p.birth?.date?.getFullYear() || '?'}</small></div>`).join('')}
</div>
</div>`;
}
// Descendants
const descendants = getDescendants(sosa1);
if (descendants.children.length > 0) {
tree.innerHTML += `<div class="tree-generation">
<div class="generation-title">⬇️ Enfants</div>
<div class="generation-people">
${descendants.children.map(p => `<div class="tree-box">${p.name}<br><small>${p.birth?.date?.getFullYear() || '?'}</small></div>`).join('')}
</div>
</div>`;
}
if (descendants.grandchildren.length > 0) {
tree.innerHTML += `<div class="tree-generation">
<div class="generation-title">⬇️ Petits-enfants</div>
<div class="generation-people">
${descendants.grandchildren.map(p => `<div class="tree-box">${p.name}<br><small>${p.birth?.date?.getFullYear() || '?'}</small></div>`).join('')}
</div>
</div>`;
}
}
function getAscendants(person) {
const parents = [];
const grandparents = [];
const greatgrandparents = [];
// Trouver la famille enfant
const familyChild = families[person.familyChild];
if (familyChild) {
if (familyChild.husband) {
const father = individuals[familyChild.husband];
if (father) {
parents.push(father);
const fatherFamily = families[father.familyChild];
if (fatherFamily) {
if (fatherFamily.husband) grandparents.push(individuals[fatherFamily.husband]);
if (fatherFamily.wife) grandparents.push(individuals[fatherFamily.wife]);
// Arrière-grands-parents
[fatherFamily.husband, fatherFamily.wife].forEach(id => {
if (id) {
const gp = individuals[id];
const gpFamily = families[gp.familyChild];
if (gpFamily) {
if (gpFamily.husband) greatgrandparents.push(individuals[gpFamily.husband]);
if (gpFamily.wife) greatgrandparents.push(individuals[gpFamily.wife]);
}
}
});
}
}
}
if (familyChild.wife) {
const mother = individuals[familyChild.wife];
if (mother) {
parents.push(mother);
const motherFamily = families[mother.familyChild];
if (motherFamily) {
if (motherFamily.husband) grandparents.push(individuals[motherFamily.husband]);
if (motherFamily.wife) grandparents.push(individuals[motherFamily.wife]);
}
}
}
}
return {
parents: parents.filter(p => p),
grandparents: grandparents.filter(p => p),
greatgrandparents: greatgrandparents.filter(p => p)
};
}
function getDescendants(person) {
const children = [];
const grandchildren = [];
// Trouver les familles où cette personne est parent
const familiesAsParent = Object.values(families).filter(f =>
f.husband === person.id || f.wife === person.id
);
familiesAsParent.forEach(fam => {
fam.children.forEach(childId => {
const child = individuals[childId];
if (child) {
children.push(child);
// Petits-enfants
const familiesOfChild = Object.values(families).filter(f =>
f.husband === childId || f.wife === childId
);
familiesOfChild.forEach(childFam => {
childFam.children.forEach(gcId => {
const gc = individuals[gcId];
if (gc) grandchildren.push(gc);
});
});
}
});
});
return {
children,
grandchildren
};
}
function displayPersonList(personnes) {
const list = document.getElementById('personList');
list.innerHTML = personnes
.sort((a, b) => (b.birth?.date || new Date()) - (a.birth?.date || new Date()))
.map(p => `
<div class="person-item" onclick="showPersonDetails('${p.id}')">
<div class="person-name">${p.name} ${p.sex === 'F' ? '👩' : '👨'}</div>
<div class="person-info">
${p.birth?.date ? p.birth.date.getFullYear() : '?'} - ${p.death?.date ? p.death.date.getFullYear() : '?'}
</div>
</div>
`).join('');
}
function displayChronology(personnes) {
const years = {};
personnes.forEach(p => {
if (p.birth?.date) {
const year = p.birth.date.getFullYear();
years[year] = (years[year] || 0) + 1;
}
});
const sortedYears = Object.entries(years).sort((a, b) => a[0] - b[0]);
const maxCount = Math.max(...sortedYears.map(y => y[1]));
const chronology = document.getElementById('chronologyContent');
chronology.innerHTML = sortedYears.map(([year, count]) => `
<div style="display: flex; align-items: center; margin: 5px 0;">
<div style="width: 50px; font-size: 12px;">${year}</div>
<div style="background: #667eea; height: 20px; width: ${(count / maxCount) * 500}px; border-radius: 3px;"></div>
<div style="margin-left: 10px; color: #666; font-size: 12px;">${count}</div>
</div>
`).join('');
}
function toggleChronology() {
const content = document.getElementById('chronologyContent');
const btn = document.getElementById('chronologyBtn');
content.classList.toggle('collapsed');
btn.textContent = content.classList.contains('collapsed') ? '▶' : '▼';
}
function filterSuggestions(query) {
const suggestions = document.getElementById('suggestions');
if (!query.trim()) {
suggestions.classList.remove('active');
return;
}
const personnes = Object.values(individuals).filter(p => p.name);
const matches = personnes.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
).slice(0, 20);
if (matches.length === 0) {
suggestions.classList.remove('active');
return;
}
suggestions.innerHTML = matches.map((p, idx) => `
<div class="suggestion-item" onclick="selectPersonFromSearch('${p.id}', this)">
${p.name} <small>(${p.birth?.date?.getFullYear() || '?'} - ${p.death?.date?.getFullYear() || '?'})</small>
</div>
`).join('');
suggestions.classList.add('active');
}
function selectPersonFromSearch(personId, element) {
const person = individuals[personId];
document.getElementById('personSearch').value = person.name;
document.getElementById('sosaSelector').value = personId;
document.getElementById('suggestions').classList.remove('active');
buildFamilyTree();
}
function handleSearchKeydown(e) {
if (e.key === 'Enter') {
const suggestions = document.getElementById('suggestions');
const items = suggestions.querySelectorAll('.suggestion-item');
if (items.length > 0) {
items[0].click();
}
}
}
function downloadAsCSV() {
const personnes = Object.values(individuals).filter(p => p.name);
// En-têtes CSV
const headers = ['ID', 'Nom', 'Sexe', 'Date Naissance', 'Lieu Naissance', 'Date Décès', 'Lieu Décès', 'Père', 'Mère'];
const rows = [headers.join(',')];
// Données
personnes.forEach(person => {
const familyChild = families[person.familyChild];
const father = familyChild?.husband ? individuals[familyChild.husband]?.name || '' : '';
const mother = familyChild?.wife ? individuals[familyChild.wife]?.name || '' : '';
const row = [
person.id.replace(/@/g, ''),
`"${person.name.replace(/"/g, '""')}"`,
person.sex === 'F' ? 'F' : person.sex === 'M' ? 'M' : '',
person.birth?.date ? person.birth.date.toLocaleDateString('fr-FR') : '',
`"${(person.birth?.place || '').replace(/"/g, '""')}"`,
person.death?.date ? person.death.date.toLocaleDateString('fr-FR') : '',
`"${(person.death?.place || '').replace(/"/g, '""')}"`,
`"${father.replace(/"/g, '""')}"`,
`"${mother.replace(/"/g, '""')}"`,
];
rows.push(row.join(','));
});
// Créer le fichier
const csv = rows.join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', 'genealogie.csv');
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function showPersonDetails(personId) {
const person = individuals[personId];
if (!person) return;
// Sélectionner cette personne comme SOSA 1
document.getElementById('sosaSelector').value = personId;
buildFamilyTree();
}
function showError(msg) {
const error = document.getElementById('error');
error.textContent = msg;
error.style.display = 'block';
}
function clearError() {
document.getElementById('error').style.display = 'none';
}
</script>
</body>
</html>