1375 lines
49 KiB
JavaScript
1375 lines
49 KiB
JavaScript
/**
|
|
* Family Tree Logger & Visual Auto-Sorting Tree Engine
|
|
*/
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// --- Local Storage Keys ---
|
|
const STORAGE_KEY = 'roesstad_family_tree_data_v2';
|
|
const THEME_KEY = 'roesstad_family_tree_theme';
|
|
|
|
// --- Sample Seed Dataset ---
|
|
const SAMPLE_FAMILY_DATA = [
|
|
{
|
|
id: 'p_1',
|
|
firstName: 'Ola',
|
|
lastName: 'Røsstad',
|
|
gender: 'male',
|
|
birthDate: '1920-03-14',
|
|
deathDate: '1995-10-22',
|
|
isLiving: false,
|
|
parents: [],
|
|
spouseId: 'p_2',
|
|
notes: 'Patriarch, fisherman and coastal farmer.'
|
|
},
|
|
{
|
|
id: 'p_2',
|
|
firstName: 'Astrid',
|
|
lastName: 'Røsstad',
|
|
gender: 'female',
|
|
birthDate: '1924-07-09',
|
|
deathDate: '2008-04-18',
|
|
isLiving: false,
|
|
parents: [],
|
|
spouseId: 'p_1',
|
|
notes: 'Master weaver, teacher, and community organizer.'
|
|
},
|
|
{
|
|
id: 'p_3',
|
|
firstName: 'Henrik',
|
|
lastName: 'Røsstad',
|
|
gender: 'male',
|
|
birthDate: '1948-05-12',
|
|
deathDate: '',
|
|
isLiving: true,
|
|
parents: ['p_1', 'p_2'],
|
|
spouseId: 'p_4',
|
|
notes: 'Civil engineer and woodwork hobbyist.'
|
|
},
|
|
{
|
|
id: 'p_4',
|
|
firstName: 'Kari',
|
|
lastName: 'Røsstad',
|
|
gender: 'female',
|
|
birthDate: '1952-11-28',
|
|
deathDate: '',
|
|
isLiving: true,
|
|
parents: [],
|
|
spouseId: 'p_3',
|
|
notes: 'Botanist and gardener.'
|
|
},
|
|
{
|
|
id: 'p_5',
|
|
firstName: 'Ingrid',
|
|
lastName: 'Røsstad',
|
|
gender: 'female',
|
|
birthDate: '1954-02-04',
|
|
deathDate: '2021-08-15',
|
|
isLiving: false,
|
|
parents: ['p_1', 'p_2'],
|
|
spouseId: null,
|
|
notes: 'Music teacher and choir director.'
|
|
},
|
|
{
|
|
id: 'p_6',
|
|
firstName: 'Magnus',
|
|
lastName: 'Røsstad',
|
|
gender: 'male',
|
|
birthDate: '1980-09-17',
|
|
deathDate: '',
|
|
isLiving: true,
|
|
parents: ['p_3', 'p_4'],
|
|
spouseId: 'p_7',
|
|
notes: 'Software architect.'
|
|
},
|
|
{
|
|
id: 'p_7',
|
|
firstName: 'Silje',
|
|
lastName: 'Berg',
|
|
gender: 'female',
|
|
birthDate: '1983-06-21',
|
|
deathDate: '',
|
|
isLiving: true,
|
|
parents: [],
|
|
spouseId: 'p_6',
|
|
notes: 'Doctor and marathon runner.'
|
|
},
|
|
{
|
|
id: 'p_8',
|
|
firstName: 'Eirik',
|
|
lastName: 'Røsstad',
|
|
gender: 'male',
|
|
birthDate: '1985-03-30',
|
|
deathDate: '',
|
|
isLiving: true,
|
|
parents: ['p_3', 'p_4'],
|
|
spouseId: null,
|
|
notes: 'Marine biologist.'
|
|
},
|
|
{
|
|
id: 'p_9',
|
|
firstName: 'Sofi',
|
|
lastName: 'Røsstad',
|
|
gender: 'female',
|
|
birthDate: '2012-12-05',
|
|
deathDate: '',
|
|
isLiving: true,
|
|
parents: ['p_6', 'p_7'],
|
|
spouseId: null,
|
|
notes: 'Youngest granddaughter, loves painting.'
|
|
},
|
|
{
|
|
id: 'p_10',
|
|
firstName: 'Lukas',
|
|
lastName: 'Røsstad',
|
|
gender: 'male',
|
|
birthDate: '2016-04-19',
|
|
deathDate: '',
|
|
isLiving: true,
|
|
parents: ['p_6', 'p_7'],
|
|
spouseId: null,
|
|
notes: 'Curious explorer and lego builder.'
|
|
}
|
|
];
|
|
|
|
// --- State ---
|
|
let familyMembers = [];
|
|
let selectedPersonId = null;
|
|
let tableSortColumn = 'birthDate';
|
|
let tableSortAsc = true;
|
|
|
|
// Pan & Zoom State
|
|
let zoomScale = 1;
|
|
let panX = 0;
|
|
let panY = 0;
|
|
let isPanning = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
|
|
// --- DOM Elements ---
|
|
const body = document.body;
|
|
const treeViewport = document.getElementById('treeViewport');
|
|
const treeCanvas = document.getElementById('treeCanvas');
|
|
const treeSvg = document.getElementById('treeSvg');
|
|
const treeNodesLayer = document.getElementById('treeNodesLayer');
|
|
const statsSubtitle = document.getElementById('statsSubtitle');
|
|
|
|
// View Panels
|
|
const viewTreeBtn = document.getElementById('viewTreeBtn');
|
|
const viewListBtn = document.getElementById('viewListBtn');
|
|
const treeViewSection = document.getElementById('treeViewSection');
|
|
const listViewSection = document.getElementById('listViewSection');
|
|
|
|
// Canvas Controls
|
|
const zoomInBtn = document.getElementById('zoomInBtn');
|
|
const zoomOutBtn = document.getElementById('zoomOutBtn');
|
|
const zoomResetBtn = document.getElementById('zoomResetBtn');
|
|
const autoSortBtn = document.getElementById('autoSortBtn');
|
|
|
|
// Search
|
|
const searchInput = document.getElementById('searchInput');
|
|
const clearSearchBtn = document.getElementById('clearSearchBtn');
|
|
|
|
// Modals & Forms
|
|
const personModal = document.getElementById('personModal');
|
|
const personModalTitle = document.getElementById('personModalTitle');
|
|
const personForm = document.getElementById('personForm');
|
|
const closePersonModalBtn = document.getElementById('closePersonModalBtn');
|
|
const cancelPersonBtn = document.getElementById('cancelPersonBtn');
|
|
|
|
const addNewPersonBtn = document.getElementById('addNewPersonBtn');
|
|
const listAddPersonBtn = document.getElementById('listAddPersonBtn');
|
|
|
|
// Form inputs
|
|
const personIdInput = document.getElementById('personId');
|
|
const firstNameInput = document.getElementById('firstNameInput');
|
|
const lastNameInput = document.getElementById('lastNameInput');
|
|
const genderSelect = document.getElementById('genderSelect');
|
|
const isLivingCheckbox = document.getElementById('isLivingCheckbox');
|
|
const birthDateInput = document.getElementById('birthDateInput');
|
|
const deathDateFieldGroup = document.getElementById('deathDateFieldGroup');
|
|
const deathDateInput = document.getElementById('deathDateInput');
|
|
const parent1Select = document.getElementById('parent1Select');
|
|
const parent2Select = document.getElementById('parent2Select');
|
|
const spouseSelect = document.getElementById('spouseSelect');
|
|
const notesInput = document.getElementById('notesInput');
|
|
|
|
// Details Modal
|
|
const detailsModal = document.getElementById('detailsModal');
|
|
const detailsModalTitle = document.getElementById('detailsModalTitle');
|
|
const detailsModalBody = document.getElementById('detailsModalBody');
|
|
const closeDetailsModalBtn = document.getElementById('closeDetailsModalBtn');
|
|
const detailsEditBtn = document.getElementById('detailsEditBtn');
|
|
const detailsDeleteBtn = document.getElementById('detailsDeleteBtn');
|
|
const detailsAddChildBtn = document.getElementById('detailsAddChildBtn');
|
|
|
|
// Data Menu & Dropdown
|
|
const dataMenuBtn = document.getElementById('dataMenuBtn');
|
|
const dataDropdownMenu = document.getElementById('dataDropdownMenu');
|
|
const exportJsonBtn = document.getElementById('exportJsonBtn');
|
|
const importJsonBtn = document.getElementById('importJsonBtn');
|
|
const importFileInput = document.getElementById('importFileInput');
|
|
const loadSampleDataBtn = document.getElementById('loadSampleDataBtn');
|
|
const clearAllDataBtn = document.getElementById('clearAllDataBtn');
|
|
|
|
// Theme Toggle
|
|
const themeToggleBtn = document.getElementById('themeToggleBtn');
|
|
const themeIcon = document.getElementById('themeIcon');
|
|
|
|
// Registry Table
|
|
const registryTable = document.getElementById('registryTable');
|
|
const registryTableBody = document.getElementById('registryTableBody');
|
|
|
|
// Toast
|
|
const toastNotification = document.getElementById('toastNotification');
|
|
|
|
// --- Helper Functions ---
|
|
|
|
function showToast(message) {
|
|
toastNotification.textContent = message;
|
|
toastNotification.classList.add('show');
|
|
setTimeout(() => {
|
|
toastNotification.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
function calculateAge(birthDateStr, deathDateStr, isLiving) {
|
|
if (!birthDateStr) return null;
|
|
const birth = new Date(birthDateStr);
|
|
if (isNaN(birth.getTime())) return null;
|
|
|
|
const end = (!isLiving && deathDateStr) ? new Date(deathDateStr) : new Date();
|
|
if (isNaN(end.getTime())) return null;
|
|
|
|
let age = end.getFullYear() - birth.getFullYear();
|
|
const m = end.getMonth() - birth.getMonth();
|
|
if (m < 0 || (m === 0 && end.getDate() < birth.getDate())) {
|
|
age--;
|
|
}
|
|
return age >= 0 ? age : 0;
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '';
|
|
const parts = dateStr.split('-');
|
|
if (parts.length === 3) {
|
|
return `${parts[0]}-${parts[1]}-${parts[2]}`;
|
|
}
|
|
return dateStr;
|
|
}
|
|
|
|
function getYear(dateStr) {
|
|
if (!dateStr) return '';
|
|
return dateStr.split('-')[0] || '';
|
|
}
|
|
|
|
// --- Storage & Data Initialization ---
|
|
|
|
function loadData() {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (saved) {
|
|
try {
|
|
familyMembers = JSON.parse(saved);
|
|
} catch (e) {
|
|
console.error("Error parsing stored family data:", e);
|
|
familyMembers = [...SAMPLE_FAMILY_DATA];
|
|
}
|
|
} else {
|
|
familyMembers = [...SAMPLE_FAMILY_DATA];
|
|
saveData();
|
|
}
|
|
}
|
|
|
|
function saveData() {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(familyMembers));
|
|
updateStats();
|
|
}
|
|
|
|
function updateStats() {
|
|
const total = familyMembers.length;
|
|
const living = familyMembers.filter(m => m.isLiving).length;
|
|
const deceased = total - living;
|
|
statsSubtitle.textContent = `${total} family members (${living} living, ${deceased} deceased)`;
|
|
}
|
|
|
|
// --- Generation & Auto-Sorting Tree Layout Engine ---
|
|
|
|
/**
|
|
* Compute generational level for each person.
|
|
* Gen 0 = oldest ancestors with no parents in the dataset.
|
|
* Children = max(parent1.gen, parent2.gen) + 1.
|
|
* Spouses aligned to the same generation level.
|
|
*/
|
|
function computeGenerations(members) {
|
|
const memberMap = new Map(members.map(m => [m.id, m]));
|
|
const genMap = new Map();
|
|
|
|
// Helper to recursively compute depth
|
|
function getGen(personId, visited = new Set()) {
|
|
if (genMap.has(personId)) return genMap.get(personId);
|
|
if (visited.has(personId)) return 0; // Prevent cycle
|
|
visited.add(personId);
|
|
|
|
const person = memberMap.get(personId);
|
|
if (!person) return 0;
|
|
|
|
const validParents = (person.parents || []).filter(pid => memberMap.has(pid));
|
|
let level = 0;
|
|
|
|
if (validParents.length > 0) {
|
|
const parentGens = validParents.map(pid => getGen(pid, new Set(visited)));
|
|
level = Math.max(...parentGens) + 1;
|
|
} else if (person.spouseId && memberMap.has(person.spouseId)) {
|
|
// If person has no parents, check if spouse has parents
|
|
const spouse = memberMap.get(person.spouseId);
|
|
const spouseParents = (spouse.parents || []).filter(pid => memberMap.has(pid));
|
|
if (spouseParents.length > 0 && !visited.has(spouse.id)) {
|
|
level = getGen(spouse.id, new Set(visited));
|
|
}
|
|
}
|
|
|
|
genMap.set(personId, level);
|
|
return level;
|
|
}
|
|
|
|
// Calculate for all
|
|
members.forEach(m => getGen(m.id));
|
|
|
|
// Pass 2: Synchronize spouses to the highest generation of the couple
|
|
members.forEach(m => {
|
|
if (m.spouseId && memberMap.has(m.spouseId)) {
|
|
const g1 = genMap.get(m.id) || 0;
|
|
const g2 = genMap.get(m.spouseId) || 0;
|
|
const maxG = Math.max(g1, g2);
|
|
genMap.set(m.id, maxG);
|
|
genMap.set(m.spouseId, maxG);
|
|
}
|
|
});
|
|
|
|
return genMap;
|
|
}
|
|
|
|
/**
|
|
* Auto-sort and build the visual tree hierarchy
|
|
*/
|
|
function renderTree() {
|
|
treeNodesLayer.innerHTML = '';
|
|
treeSvg.innerHTML = '';
|
|
|
|
if (familyMembers.length === 0) {
|
|
treeNodesLayer.innerHTML = `
|
|
<div class="empty-state">
|
|
<h3>No family members logged yet</h3>
|
|
<p>Start recording your family history by adding your first person or loading the sample tree.</p>
|
|
<button class="btn btn-primary" onclick="document.getElementById('addNewPersonBtn').click()">
|
|
+ Add First Person
|
|
</button>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const genMap = computeGenerations(familyMembers);
|
|
const memberMap = new Map(familyMembers.map(m => [m.id, m]));
|
|
|
|
// Group members by generation
|
|
const generations = new Map();
|
|
familyMembers.forEach(m => {
|
|
const gen = genMap.get(m.id) || 0;
|
|
if (!generations.has(gen)) {
|
|
generations.set(gen, []);
|
|
}
|
|
generations.get(gen).push(m);
|
|
});
|
|
|
|
// Sorted generation levels (0, 1, 2, 3...)
|
|
const sortedGenLevels = Array.from(generations.keys()).sort((a, b) => a - b);
|
|
|
|
// Process each generation level
|
|
sortedGenLevels.forEach(genLevel => {
|
|
const membersInGen = generations.get(genLevel);
|
|
|
|
// Auto-sort members within generation chronologically by birth date
|
|
membersInGen.sort((a, b) => {
|
|
const dateA = a.birthDate || '9999-99-99';
|
|
const dateB = b.birthDate || '9999-99-99';
|
|
return dateA.localeCompare(dateB);
|
|
});
|
|
|
|
// Container row for this generation
|
|
const rowDiv = document.createElement('div');
|
|
rowDiv.className = 'generation-row';
|
|
rowDiv.dataset.gen = genLevel;
|
|
|
|
// Generation label tag
|
|
const labelTag = document.createElement('div');
|
|
labelTag.className = 'generation-label-tag';
|
|
labelTag.textContent = `Generation ${genLevel + 1}`;
|
|
rowDiv.appendChild(labelTag);
|
|
|
|
// Cluster into couples and single individuals to render side-by-side
|
|
const renderedIds = new Set();
|
|
|
|
membersInGen.forEach(person => {
|
|
if (renderedIds.has(person.id)) return;
|
|
|
|
const hasSpouseInGen = person.spouseId &&
|
|
memberMap.has(person.spouseId) &&
|
|
genMap.get(person.spouseId) === genLevel;
|
|
|
|
if (hasSpouseInGen) {
|
|
const spouse = memberMap.get(person.spouseId);
|
|
renderedIds.add(person.id);
|
|
renderedIds.add(spouse.id);
|
|
|
|
// Couple container
|
|
const coupleDiv = document.createElement('div');
|
|
coupleDiv.className = 'couple-container';
|
|
coupleDiv.id = `couple_${person.id}_${spouse.id}`;
|
|
|
|
// Determine couple order: male on left or older on left
|
|
let p1 = person;
|
|
let p2 = spouse;
|
|
if (person.gender === 'female' && spouse.gender === 'male') {
|
|
p1 = spouse;
|
|
p2 = person;
|
|
}
|
|
|
|
coupleDiv.appendChild(createPersonCard(p1));
|
|
|
|
const marriageSymbol = document.createElement('div');
|
|
marriageSymbol.className = 'marriage-symbol';
|
|
marriageSymbol.innerHTML = '💍';
|
|
marriageSymbol.title = `Spouse / Partner connection`;
|
|
coupleDiv.appendChild(marriageSymbol);
|
|
|
|
coupleDiv.appendChild(createPersonCard(p2));
|
|
|
|
rowDiv.appendChild(coupleDiv);
|
|
} else {
|
|
renderedIds.add(person.id);
|
|
rowDiv.appendChild(createPersonCard(person));
|
|
}
|
|
});
|
|
|
|
treeNodesLayer.appendChild(rowDiv);
|
|
});
|
|
|
|
// Once DOM elements are placed, calculate and draw connecting branches
|
|
requestAnimationFrame(() => {
|
|
drawRelationshipLines(memberMap);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a person card DOM element
|
|
*/
|
|
function createPersonCard(person) {
|
|
const card = document.createElement('div');
|
|
card.className = `person-card ${person.gender || 'other'}`;
|
|
card.id = `card_${person.id}`;
|
|
card.dataset.personId = person.id;
|
|
|
|
const age = calculateAge(person.birthDate, person.deathDate, person.isLiving);
|
|
const bYear = getYear(person.birthDate) || '?';
|
|
const dYear = getYear(person.deathDate);
|
|
|
|
let dateDisplay = `* ${bYear}`;
|
|
if (!person.isLiving && dYear) {
|
|
dateDisplay += ` † ${dYear}`;
|
|
}
|
|
|
|
let ageBadge = '';
|
|
if (age !== null) {
|
|
ageBadge = person.isLiving ? `${age} yrs (Living)` : `Died at ${age} yrs`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="card-top">
|
|
<div class="card-name">${escapeHtml(person.firstName)} ${escapeHtml(person.lastName)}</div>
|
|
<span class="status-indicator ${person.isLiving ? 'living' : 'deceased'}">
|
|
${person.isLiving ? 'Living' : 'Deceased'}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="card-dates">
|
|
<div class="date-row">
|
|
<span class="date-symbol">📅</span>
|
|
<span>${dateDisplay}</span>
|
|
</div>
|
|
${ageBadge ? `<div class="card-lifespan-badge">${ageBadge}</div>` : ''}
|
|
</div>
|
|
|
|
<div class="card-quick-actions">
|
|
<button class="card-action-btn action-add-child" title="Add Child" data-id="${person.id}">+ Child</button>
|
|
<button class="card-action-btn action-edit" title="Edit Person" data-id="${person.id}">Edit</button>
|
|
</div>
|
|
`;
|
|
|
|
// Card Click opens details modal
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest('.card-action-btn')) return;
|
|
openDetailsModal(person.id);
|
|
});
|
|
|
|
// Quick action buttons
|
|
card.querySelector('.action-add-child').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
openAddPersonModal({ parent1Id: person.id });
|
|
});
|
|
|
|
card.querySelector('.action-edit').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
openEditPersonModal(person.id);
|
|
});
|
|
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* Draw SVG curved connecting branches between parents and children
|
|
*/
|
|
function drawRelationshipLines(memberMap) {
|
|
treeSvg.innerHTML = '';
|
|
|
|
const layerRect = treeNodesLayer.getBoundingClientRect();
|
|
if (layerRect.width === 0 || layerRect.height === 0) return;
|
|
|
|
// Resize SVG coordinate canvas to match layer
|
|
treeSvg.setAttribute('width', treeNodesLayer.scrollWidth);
|
|
treeSvg.setAttribute('height', treeNodesLayer.scrollHeight);
|
|
|
|
// Group children by parent combinations
|
|
const familyGroups = new Map();
|
|
|
|
familyMembers.forEach(child => {
|
|
const validParents = (child.parents || []).filter(pid => memberMap.has(pid));
|
|
if (validParents.length === 0) return;
|
|
|
|
// Sort parent IDs to ensure consistent key
|
|
const parentsKey = [...validParents].sort().join('___');
|
|
if (!familyGroups.has(parentsKey)) {
|
|
familyGroups.set(parentsKey, {
|
|
parentIds: validParents,
|
|
childrenIds: []
|
|
});
|
|
}
|
|
familyGroups.get(parentsKey).childrenIds.push(child.id);
|
|
});
|
|
|
|
// Draw connections for each parent group
|
|
familyGroups.forEach(group => {
|
|
const { parentIds, childrenIds } = group;
|
|
|
|
// Compute origin coordinates for parents
|
|
let originX = 0;
|
|
let originY = 0;
|
|
|
|
if (parentIds.length === 2) {
|
|
const card1 = document.getElementById(`card_${parentIds[0]}`);
|
|
const card2 = document.getElementById(`card_${parentIds[1]}`);
|
|
|
|
if (card1 && card2) {
|
|
const r1 = card1.getBoundingClientRect();
|
|
const r2 = card2.getBoundingClientRect();
|
|
|
|
const x1 = (r1.left + r1.right) / 2 - layerRect.left;
|
|
const x2 = (r2.left + r2.right) / 2 - layerRect.left;
|
|
const y1 = r1.bottom - layerRect.top;
|
|
const y2 = r2.bottom - layerRect.top;
|
|
|
|
originX = (x1 + x2) / 2;
|
|
originY = Math.max(y1, y2);
|
|
} else if (card1 || card2) {
|
|
const single = card1 || card2;
|
|
const r = single.getBoundingClientRect();
|
|
originX = (r.left + r.right) / 2 - layerRect.left;
|
|
originY = r.bottom - layerRect.top;
|
|
}
|
|
} else if (parentIds.length === 1) {
|
|
const card = document.getElementById(`card_${parentIds[0]}`);
|
|
if (card) {
|
|
const r = card.getBoundingClientRect();
|
|
originX = (r.left + r.right) / 2 - layerRect.left;
|
|
originY = r.bottom - layerRect.top;
|
|
}
|
|
}
|
|
|
|
if (!originX || !originY) return;
|
|
|
|
// Collect valid child card coordinates
|
|
const childCoords = [];
|
|
childrenIds.forEach(cid => {
|
|
const cCard = document.getElementById(`card_${cid}`);
|
|
if (cCard) {
|
|
const cr = cCard.getBoundingClientRect();
|
|
childCoords.push({
|
|
id: cid,
|
|
x: (cr.left + cr.right) / 2 - layerRect.left,
|
|
y: cr.top - layerRect.top
|
|
});
|
|
}
|
|
});
|
|
|
|
if (childCoords.length === 0) return;
|
|
|
|
// Compute children bus line level
|
|
const minChildY = Math.min(...childCoords.map(c => c.y));
|
|
const busY = originY + (minChildY - originY) * 0.5;
|
|
|
|
// Path 1: Down from parent(s) origin to bus bar
|
|
const stemPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
stemPath.setAttribute('class', 'relation-line');
|
|
stemPath.setAttribute('d', `M ${originX} ${originY} L ${originX} ${busY}`);
|
|
treeSvg.appendChild(stemPath);
|
|
|
|
// Path 2: Horizontal bus bar covering all children and parent origin
|
|
const allXs = [...childCoords.map(c => c.x), originX];
|
|
const minX = Math.min(...allXs);
|
|
const maxX = Math.max(...allXs);
|
|
|
|
if (maxX - minX > 2) {
|
|
const busPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
busPath.setAttribute('class', 'relation-line');
|
|
busPath.setAttribute('d', `M ${minX} ${busY} L ${maxX} ${busY}`);
|
|
treeSvg.appendChild(busPath);
|
|
}
|
|
|
|
// Path 3: Drop vertical lines from bus bar into each child card
|
|
childCoords.forEach(c => {
|
|
const childPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
childPath.setAttribute('class', 'relation-line');
|
|
childPath.setAttribute('d', `M ${c.x} ${busY} L ${c.x} ${c.y}`);
|
|
treeSvg.appendChild(childPath);
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Registry Table View ---
|
|
|
|
function renderRegistryTable() {
|
|
const memberMap = new Map(familyMembers.map(m => [m.id, m]));
|
|
|
|
// Copy and sort
|
|
const sortedList = [...familyMembers].sort((a, b) => {
|
|
let valA = a[tableSortColumn] || '';
|
|
let valB = b[tableSortColumn] || '';
|
|
|
|
if (tableSortColumn === 'name') {
|
|
valA = `${a.firstName} ${a.lastName}`.toLowerCase();
|
|
valB = `${b.firstName} ${b.lastName}`.toLowerCase();
|
|
} else if (tableSortColumn === 'lifespan') {
|
|
valA = calculateAge(a.birthDate, a.deathDate, a.isLiving) || -1;
|
|
valB = calculateAge(b.birthDate, b.deathDate, b.isLiving) || -1;
|
|
return tableSortAsc ? valA - valB : valB - valA;
|
|
}
|
|
|
|
if (valA < valB) return tableSortAsc ? -1 : 1;
|
|
if (valA > valB) return tableSortAsc ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
registryTableBody.innerHTML = '';
|
|
|
|
if (sortedList.length === 0) {
|
|
registryTableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
|
No members recorded yet.
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
sortedList.forEach(m => {
|
|
const tr = document.createElement('tr');
|
|
|
|
// Parents display
|
|
const parentNames = (m.parents || [])
|
|
.map(pid => memberMap.get(pid))
|
|
.filter(Boolean)
|
|
.map(p => `${p.firstName} ${p.lastName}`)
|
|
.join(', ') || '—';
|
|
|
|
// Spouse display
|
|
let spouseName = '—';
|
|
if (m.spouseId && memberMap.has(m.spouseId)) {
|
|
const sp = memberMap.get(m.spouseId);
|
|
spouseName = `${sp.firstName} ${sp.lastName}`;
|
|
}
|
|
|
|
const age = calculateAge(m.birthDate, m.deathDate, m.isLiving);
|
|
let lifespanText = '—';
|
|
if (age !== null) {
|
|
lifespanText = m.isLiving ? `${age} (Living)` : `${age} yrs`;
|
|
}
|
|
|
|
tr.innerHTML = `
|
|
<td>
|
|
<span class="table-person-name">${escapeHtml(m.firstName)} ${escapeHtml(m.lastName)}</span>
|
|
</td>
|
|
<td>
|
|
<span class="gender-tag ${m.gender || 'other'}">${m.gender || 'other'}</span>
|
|
</td>
|
|
<td>${formatDate(m.birthDate)}</td>
|
|
<td>${m.isLiving ? '<span class="status-indicator living">Living</span>' : formatDate(m.deathDate)}</td>
|
|
<td>${lifespanText}</td>
|
|
<td>${escapeHtml(parentNames)}</td>
|
|
<td>${escapeHtml(spouseName)}</td>
|
|
<td>
|
|
<div style="display: flex; gap: 6px;">
|
|
<button class="btn btn-secondary" style="padding: 3px 8px; font-size: 0.75rem;" data-action="locate" data-id="${m.id}" title="Center in tree">Locate</button>
|
|
<button class="btn btn-secondary" style="padding: 3px 8px; font-size: 0.75rem;" data-action="edit" data-id="${m.id}">Edit</button>
|
|
</div>
|
|
</td>
|
|
`;
|
|
|
|
// Action delegates
|
|
tr.querySelector('[data-action="locate"]').addEventListener('click', () => {
|
|
switchToView('tree');
|
|
centerOnPerson(m.id);
|
|
});
|
|
|
|
tr.querySelector('[data-action="edit"]').addEventListener('click', () => {
|
|
openEditPersonModal(m.id);
|
|
});
|
|
|
|
registryTableBody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
// --- Modal Handling ---
|
|
|
|
function populateRelationshipDropdowns(currentPersonId = null) {
|
|
parent1Select.innerHTML = '<option value="">-- None / Unknown --</option>';
|
|
parent2Select.innerHTML = '<option value="">-- None / Unknown --</option>';
|
|
spouseSelect.innerHTML = '<option value="">-- None / Unmarried --</option>';
|
|
|
|
familyMembers
|
|
.filter(m => m.id !== currentPersonId)
|
|
.sort((a, b) => `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`))
|
|
.forEach(m => {
|
|
const text = `${m.firstName} ${m.lastName} (*${getYear(m.birthDate) || '?'})`;
|
|
|
|
const optP1 = document.createElement('option');
|
|
optP1.value = m.id;
|
|
optP1.textContent = text;
|
|
parent1Select.appendChild(optP1);
|
|
|
|
const optP2 = document.createElement('option');
|
|
optP2.value = m.id;
|
|
optP2.textContent = text;
|
|
parent2Select.appendChild(optP2);
|
|
|
|
const optSpouse = document.createElement('option');
|
|
optSpouse.value = m.id;
|
|
optSpouse.textContent = text;
|
|
spouseSelect.appendChild(optSpouse);
|
|
});
|
|
}
|
|
|
|
function openAddPersonModal(prefills = {}) {
|
|
personModalTitle.textContent = 'Add Family Member';
|
|
personForm.reset();
|
|
personIdInput.value = '';
|
|
|
|
populateRelationshipDropdowns(null);
|
|
|
|
// Pre-select defaults
|
|
isLivingCheckbox.checked = true;
|
|
deathDateFieldGroup.style.display = 'none';
|
|
deathDateInput.removeAttribute('required');
|
|
|
|
if (prefills.parent1Id) {
|
|
parent1Select.value = prefills.parent1Id;
|
|
}
|
|
if (prefills.spouseId) {
|
|
spouseSelect.value = prefills.spouseId;
|
|
}
|
|
|
|
personModal.style.display = 'flex';
|
|
firstNameInput.focus();
|
|
}
|
|
|
|
function openEditPersonModal(personId) {
|
|
const person = familyMembers.find(m => m.id === personId);
|
|
if (!person) return;
|
|
|
|
personModalTitle.textContent = 'Edit Family Member';
|
|
personForm.reset();
|
|
personIdInput.value = person.id;
|
|
|
|
populateRelationshipDropdowns(person.id);
|
|
|
|
firstNameInput.value = person.firstName;
|
|
lastNameInput.value = person.lastName;
|
|
genderSelect.value = person.gender || 'male';
|
|
|
|
isLivingCheckbox.checked = person.isLiving;
|
|
if (person.isLiving) {
|
|
deathDateFieldGroup.style.display = 'none';
|
|
deathDateInput.removeAttribute('required');
|
|
deathDateInput.value = '';
|
|
} else {
|
|
deathDateFieldGroup.style.display = 'block';
|
|
deathDateInput.value = person.deathDate || '';
|
|
}
|
|
|
|
birthDateInput.value = person.birthDate || '';
|
|
|
|
const p1 = (person.parents && person.parents[0]) || '';
|
|
const p2 = (person.parents && person.parents[1]) || '';
|
|
parent1Select.value = p1;
|
|
parent2Select.value = p2;
|
|
|
|
spouseSelect.value = person.spouseId || '';
|
|
notesInput.value = person.notes || '';
|
|
|
|
personModal.style.display = 'flex';
|
|
}
|
|
|
|
function closePersonModal() {
|
|
personModal.style.display = 'none';
|
|
}
|
|
|
|
// Person Form Submit
|
|
personForm.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
|
|
const id = personIdInput.value || `p_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
|
const isLiving = isLivingCheckbox.checked;
|
|
const bDate = birthDateInput.value;
|
|
const dDate = isLiving ? '' : deathDateInput.value;
|
|
|
|
// Validation
|
|
if (!isLiving && dDate && bDate && dDate < bDate) {
|
|
alert("Death date cannot precede birth date.");
|
|
return;
|
|
}
|
|
|
|
const parents = [];
|
|
if (parent1Select.value) parents.push(parent1Select.value);
|
|
if (parent2Select.value && parent2Select.value !== parent1Select.value) {
|
|
parents.push(parent2Select.value);
|
|
}
|
|
|
|
const spouseId = spouseSelect.value || null;
|
|
|
|
const updatedPerson = {
|
|
id,
|
|
firstName: firstNameInput.value.trim(),
|
|
lastName: lastNameInput.value.trim(),
|
|
gender: genderSelect.value,
|
|
birthDate: bDate,
|
|
deathDate: dDate,
|
|
isLiving,
|
|
parents,
|
|
spouseId,
|
|
notes: notesInput.value.trim()
|
|
};
|
|
|
|
const existingIdx = familyMembers.findIndex(m => m.id === id);
|
|
if (existingIdx >= 0) {
|
|
familyMembers[existingIdx] = updatedPerson;
|
|
} else {
|
|
familyMembers.push(updatedPerson);
|
|
}
|
|
|
|
// Mutual spouse update
|
|
if (spouseId) {
|
|
const spouse = familyMembers.find(m => m.id === spouseId);
|
|
if (spouse && spouse.spouseId !== id) {
|
|
spouse.spouseId = id;
|
|
}
|
|
} else {
|
|
// Remove lingering spouse pointers to this person
|
|
familyMembers.forEach(m => {
|
|
if (m.spouseId === id) m.spouseId = null;
|
|
});
|
|
}
|
|
|
|
saveData();
|
|
closePersonModal();
|
|
renderTree();
|
|
renderRegistryTable();
|
|
showToast(`Saved ${updatedPerson.firstName} ${updatedPerson.lastName}`);
|
|
});
|
|
|
|
// Details Modal
|
|
function openDetailsModal(personId) {
|
|
const person = familyMembers.find(m => m.id === personId);
|
|
if (!person) return;
|
|
selectedPersonId = personId;
|
|
|
|
const memberMap = new Map(familyMembers.map(m => [m.id, m]));
|
|
detailsModalTitle.textContent = `${person.firstName} ${person.lastName}`;
|
|
|
|
const age = calculateAge(person.birthDate, person.deathDate, person.isLiving);
|
|
let ageStr = '—';
|
|
if (age !== null) {
|
|
ageStr = person.isLiving ? `${age} years old (Living)` : `${age} years (Deceased)`;
|
|
}
|
|
|
|
const parentNames = (person.parents || [])
|
|
.map(pid => memberMap.get(pid))
|
|
.filter(Boolean)
|
|
.map(p => `<a href="#" class="detail-link" data-id="${p.id}">${escapeHtml(p.firstName)} ${escapeHtml(p.lastName)}</a>`)
|
|
.join(', ') || 'None recorded';
|
|
|
|
let spouseHtml = 'None recorded';
|
|
if (person.spouseId && memberMap.has(person.spouseId)) {
|
|
const sp = memberMap.get(person.spouseId);
|
|
spouseHtml = `<a href="#" class="detail-link" data-id="${sp.id}">${escapeHtml(sp.firstName)} ${escapeHtml(sp.lastName)}</a>`;
|
|
}
|
|
|
|
// Children of this person
|
|
const children = familyMembers.filter(m => (m.parents || []).includes(person.id));
|
|
const childrenHtml = children.length > 0
|
|
? children.map(c => `<a href="#" class="detail-link" data-id="${c.id}">${escapeHtml(c.firstName)} ${escapeHtml(c.lastName)}</a>`).join(', ')
|
|
: 'None recorded';
|
|
|
|
detailsModalBody.innerHTML = `
|
|
<div class="detail-item">
|
|
<div class="detail-label">Gender</div>
|
|
<div class="detail-val" style="text-transform: capitalize;">${person.gender || 'Not specified'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Birth Date</div>
|
|
<div class="detail-val">${formatDate(person.birthDate) || '—'}</div>
|
|
</div>
|
|
${!person.isLiving ? `
|
|
<div class="detail-item">
|
|
<div class="detail-label">Death Date</div>
|
|
<div class="detail-val">${formatDate(person.deathDate) || '—'}</div>
|
|
</div>
|
|
` : ''}
|
|
<div class="detail-item">
|
|
<div class="detail-label">Lifespan / Status</div>
|
|
<div class="detail-val">${ageStr}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Parents</div>
|
|
<div class="detail-val">${parentNames}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Spouse / Partner</div>
|
|
<div class="detail-val">${spouseHtml}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Children</div>
|
|
<div class="detail-val">${childrenHtml}</div>
|
|
</div>
|
|
${person.notes ? `
|
|
<div class="detail-item">
|
|
<div class="detail-label">Notes & Biography</div>
|
|
<div class="detail-val" style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.4;">${escapeHtml(person.notes)}</div>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
|
|
// Add clickable links inside modal to inspect relatives
|
|
detailsModalBody.querySelectorAll('.detail-link').forEach(link => {
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
openDetailsModal(link.dataset.id);
|
|
});
|
|
});
|
|
|
|
detailsModal.style.display = 'flex';
|
|
}
|
|
|
|
function closeDetailsModal() {
|
|
detailsModal.style.display = 'none';
|
|
selectedPersonId = null;
|
|
}
|
|
|
|
// Delete Person
|
|
function deletePerson(personId) {
|
|
const person = familyMembers.find(m => m.id === personId);
|
|
if (!person) return;
|
|
|
|
if (!confirm(`Are you sure you want to delete ${person.firstName} ${person.lastName} from the tree?`)) {
|
|
return;
|
|
}
|
|
|
|
// Remove from list
|
|
familyMembers = familyMembers.filter(m => m.id !== personId);
|
|
|
|
// Remove references from other members
|
|
familyMembers.forEach(m => {
|
|
if (m.spouseId === personId) m.spouseId = null;
|
|
if (m.parents) {
|
|
m.parents = m.parents.filter(pid => pid !== personId);
|
|
}
|
|
});
|
|
|
|
saveData();
|
|
closeDetailsModal();
|
|
renderTree();
|
|
renderRegistryTable();
|
|
showToast(`Removed ${person.firstName} ${person.lastName}`);
|
|
}
|
|
|
|
// --- Pan & Zoom Canvas Engine ---
|
|
|
|
function applyTransform() {
|
|
treeCanvas.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`;
|
|
}
|
|
|
|
function resetZoom() {
|
|
zoomScale = 1;
|
|
panX = 40;
|
|
panY = 40;
|
|
applyTransform();
|
|
}
|
|
|
|
function fitToScreen() {
|
|
const viewportRect = treeViewport.getBoundingClientRect();
|
|
const contentWidth = treeNodesLayer.scrollWidth;
|
|
const contentHeight = treeNodesLayer.scrollHeight;
|
|
|
|
if (contentWidth === 0 || contentHeight === 0) {
|
|
resetZoom();
|
|
return;
|
|
}
|
|
|
|
const scaleX = (viewportRect.width - 80) / contentWidth;
|
|
const scaleY = (viewportRect.height - 80) / contentHeight;
|
|
zoomScale = Math.min(Math.max(Math.min(scaleX, scaleY), 0.35), 1.2);
|
|
|
|
panX = (viewportRect.width - contentWidth * zoomScale) / 2;
|
|
panY = 40;
|
|
applyTransform();
|
|
}
|
|
|
|
function centerOnPerson(personId) {
|
|
const card = document.getElementById(`card_${personId}`);
|
|
if (!card) return;
|
|
|
|
// Visual pulse
|
|
document.querySelectorAll('.person-card').forEach(c => c.classList.remove('search-match', 'selected'));
|
|
card.classList.add('search-match', 'selected');
|
|
|
|
const viewportRect = treeViewport.getBoundingClientRect();
|
|
const cardRect = card.getBoundingClientRect();
|
|
const layerRect = treeNodesLayer.getBoundingClientRect();
|
|
|
|
const cardX = (cardRect.left + cardRect.width / 2 - layerRect.left);
|
|
const cardY = (cardRect.top + cardRect.height / 2 - layerRect.top);
|
|
|
|
panX = viewportRect.width / 2 - cardX * zoomScale;
|
|
panY = viewportRect.height / 2 - cardY * zoomScale;
|
|
|
|
treeCanvas.style.transition = 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)';
|
|
applyTransform();
|
|
setTimeout(() => {
|
|
treeCanvas.style.transition = '';
|
|
}, 400);
|
|
}
|
|
|
|
// Mouse Pan
|
|
treeViewport.addEventListener('mousedown', (e) => {
|
|
if (e.target.closest('.person-card') || e.target.closest('.canvas-controls')) return;
|
|
isPanning = true;
|
|
treeViewport.classList.add('panning');
|
|
startX = e.clientX - panX;
|
|
startY = e.clientY - panY;
|
|
});
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (!isPanning) return;
|
|
panX = e.clientX - startX;
|
|
panY = e.clientY - startY;
|
|
applyTransform();
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
isPanning = false;
|
|
treeViewport.classList.remove('panning');
|
|
});
|
|
|
|
// Mouse Wheel Zoom
|
|
treeViewport.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const zoomFactor = 1.1;
|
|
const rect = treeViewport.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
const currentScale = zoomScale;
|
|
let newScale;
|
|
|
|
if (e.deltaY < 0) {
|
|
newScale = Math.min(zoomScale * zoomFactor, 2.5);
|
|
} else {
|
|
newScale = Math.max(zoomScale / zoomFactor, 0.3);
|
|
}
|
|
|
|
// Adjust pan to zoom relative to cursor position
|
|
panX = mouseX - (mouseX - panX) * (newScale / currentScale);
|
|
panY = mouseY - (mouseY - panY) * (newScale / currentScale);
|
|
zoomScale = newScale;
|
|
|
|
applyTransform();
|
|
}, { passive: false });
|
|
|
|
// --- Search Engine ---
|
|
|
|
searchInput.addEventListener('input', () => {
|
|
const query = searchInput.value.trim().toLowerCase();
|
|
clearSearchBtn.classList.toggle('active', query.length > 0);
|
|
|
|
if (!query) {
|
|
document.querySelectorAll('.person-card').forEach(c => c.classList.remove('search-match'));
|
|
return;
|
|
}
|
|
|
|
let firstMatchId = null;
|
|
familyMembers.forEach(m => {
|
|
const fullName = `${m.firstName} ${m.lastName}`.toLowerCase();
|
|
const bYear = getYear(m.birthDate);
|
|
const dYear = getYear(m.deathDate);
|
|
const card = document.getElementById(`card_${m.id}`);
|
|
|
|
const matches = fullName.includes(query) || bYear.includes(query) || dYear.includes(query);
|
|
|
|
if (card) {
|
|
card.classList.toggle('search-match', matches);
|
|
}
|
|
|
|
if (matches && !firstMatchId) {
|
|
firstMatchId = m.id;
|
|
}
|
|
});
|
|
|
|
if (firstMatchId) {
|
|
centerOnPerson(firstMatchId);
|
|
}
|
|
});
|
|
|
|
clearSearchBtn.addEventListener('click', () => {
|
|
searchInput.value = '';
|
|
clearSearchBtn.classList.remove('active');
|
|
document.querySelectorAll('.person-card').forEach(c => c.classList.remove('search-match'));
|
|
});
|
|
|
|
// --- View Navigation ---
|
|
|
|
function switchToView(mode) {
|
|
if (mode === 'tree') {
|
|
viewTreeBtn.classList.add('active');
|
|
viewListBtn.classList.remove('active');
|
|
treeViewSection.classList.add('active');
|
|
listViewSection.classList.remove('active');
|
|
renderTree();
|
|
} else {
|
|
viewListBtn.classList.add('active');
|
|
viewTreeBtn.classList.remove('active');
|
|
listViewSection.classList.add('active');
|
|
treeViewSection.classList.remove('active');
|
|
renderRegistryTable();
|
|
}
|
|
}
|
|
|
|
viewTreeBtn.addEventListener('click', () => switchToView('tree'));
|
|
viewListBtn.addEventListener('click', () => switchToView('list'));
|
|
|
|
// --- Theme Management ---
|
|
|
|
function initTheme() {
|
|
const savedTheme = localStorage.getItem(THEME_KEY) || 'dark';
|
|
if (savedTheme === 'light') {
|
|
body.classList.remove('dark-theme');
|
|
body.classList.add('light-theme');
|
|
themeIcon.textContent = '☀️';
|
|
} else {
|
|
body.classList.remove('light-theme');
|
|
body.classList.add('dark-theme');
|
|
themeIcon.textContent = '🌙';
|
|
}
|
|
}
|
|
|
|
themeToggleBtn.addEventListener('click', () => {
|
|
const isLight = body.classList.toggle('light-theme');
|
|
body.classList.toggle('dark-theme', !isLight);
|
|
themeIcon.textContent = isLight ? '☀️' : '🌙';
|
|
localStorage.setItem(THEME_KEY, isLight ? 'light' : 'dark');
|
|
// Redraw SVG lines for color adjustment
|
|
renderTree();
|
|
});
|
|
|
|
// --- Backup & Data Management ---
|
|
|
|
dataMenuBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
dataDropdownMenu.classList.toggle('show');
|
|
});
|
|
|
|
window.addEventListener('click', () => {
|
|
dataDropdownMenu.classList.remove('show');
|
|
});
|
|
|
|
// Export JSON
|
|
exportJsonBtn.addEventListener('click', () => {
|
|
const dataStr = JSON.stringify(familyMembers, null, 2);
|
|
const blob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `roesstad_family_tree_${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast("Exported family tree to JSON file.");
|
|
});
|
|
|
|
// Import JSON
|
|
importJsonBtn.addEventListener('click', () => {
|
|
importFileInput.click();
|
|
});
|
|
|
|
importFileInput.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
try {
|
|
const parsed = JSON.parse(event.target.result);
|
|
if (Array.isArray(parsed)) {
|
|
familyMembers = parsed;
|
|
saveData();
|
|
renderTree();
|
|
renderRegistryTable();
|
|
fitToScreen();
|
|
showToast(`Successfully imported ${familyMembers.length} records!`);
|
|
} else {
|
|
alert("Invalid file structure. Expected JSON array of members.");
|
|
}
|
|
} catch (err) {
|
|
alert("Failed to parse JSON file: " + err.message);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
importFileInput.value = '';
|
|
});
|
|
|
|
// Load Sample Data
|
|
loadSampleDataBtn.addEventListener('click', () => {
|
|
if (confirm("Reset to sample 4-generation family dataset? Any unsaved custom records will be replaced.")) {
|
|
familyMembers = JSON.parse(JSON.stringify(SAMPLE_FAMILY_DATA));
|
|
saveData();
|
|
renderTree();
|
|
renderRegistryTable();
|
|
fitToScreen();
|
|
showToast("Sample family tree loaded!");
|
|
}
|
|
});
|
|
|
|
// Clear All Records
|
|
clearAllDataBtn.addEventListener('click', () => {
|
|
if (confirm("Are you sure you want to clear all family tree records and start fresh?")) {
|
|
familyMembers = [];
|
|
saveData();
|
|
renderTree();
|
|
renderRegistryTable();
|
|
showToast("All records cleared. Tree is blank.");
|
|
}
|
|
});
|
|
|
|
// --- Event Listeners Wiring ---
|
|
|
|
addNewPersonBtn.addEventListener('click', () => openAddPersonModal());
|
|
listAddPersonBtn.addEventListener('click', () => openAddPersonModal());
|
|
closePersonModalBtn.addEventListener('click', closePersonModal);
|
|
cancelPersonBtn.addEventListener('click', closePersonModal);
|
|
|
|
isLivingCheckbox.addEventListener('change', () => {
|
|
deathDateFieldGroup.style.display = isLivingCheckbox.checked ? 'none' : 'block';
|
|
});
|
|
|
|
closeDetailsModalBtn.addEventListener('click', closeDetailsModal);
|
|
|
|
detailsEditBtn.addEventListener('click', () => {
|
|
const id = selectedPersonId;
|
|
closeDetailsModal();
|
|
if (id) openEditPersonModal(id);
|
|
});
|
|
|
|
detailsDeleteBtn.addEventListener('click', () => {
|
|
const id = selectedPersonId;
|
|
if (id) deletePerson(id);
|
|
});
|
|
|
|
detailsAddChildBtn.addEventListener('click', () => {
|
|
const id = selectedPersonId;
|
|
closeDetailsModal();
|
|
if (id) openAddPersonModal({ parent1Id: id });
|
|
});
|
|
|
|
zoomInBtn.addEventListener('click', () => {
|
|
zoomScale = Math.min(zoomScale * 1.2, 2.5);
|
|
applyTransform();
|
|
});
|
|
|
|
zoomOutBtn.addEventListener('click', () => {
|
|
zoomScale = Math.max(zoomScale / 1.2, 0.3);
|
|
applyTransform();
|
|
});
|
|
|
|
zoomResetBtn.addEventListener('click', fitToScreen);
|
|
autoSortBtn.addEventListener('click', () => {
|
|
renderTree();
|
|
fitToScreen();
|
|
showToast("Tree re-sorted and aligned.");
|
|
});
|
|
|
|
// Table sorting headers
|
|
registryTable.querySelectorAll('th[data-sort]').forEach(th => {
|
|
th.addEventListener('click', () => {
|
|
const col = th.dataset.sort;
|
|
if (tableSortColumn === col) {
|
|
tableSortAsc = !tableSortAsc;
|
|
} else {
|
|
tableSortColumn = col;
|
|
tableSortAsc = true;
|
|
}
|
|
renderRegistryTable();
|
|
});
|
|
});
|
|
|
|
// Window resize redraws SVG branch lines
|
|
window.addEventListener('resize', () => {
|
|
if (treeViewSection.classList.contains('active')) {
|
|
const memberMap = new Map(familyMembers.map(m => [m.id, m]));
|
|
drawRelationshipLines(memberMap);
|
|
}
|
|
});
|
|
|
|
// Escape Key closes modals
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closePersonModal();
|
|
closeDetailsModal();
|
|
}
|
|
});
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// --- App Start ---
|
|
initTheme();
|
|
loadData();
|
|
renderTree();
|
|
renderRegistryTable();
|
|
setTimeout(fitToScreen, 100);
|
|
|
|
})();
|