/** * 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 = `

No family members logged yet

Start recording your family history by adding your first person or loading the sample tree.

`; 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 = `
${escapeHtml(person.firstName)} ${escapeHtml(person.lastName)}
${person.isLiving ? 'Living' : 'Deceased'}
📅 ${dateDisplay}
${ageBadge ? `
${ageBadge}
` : ''}
`; // 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 = ` No members recorded yet. `; 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 = ` ${escapeHtml(m.firstName)} ${escapeHtml(m.lastName)} ${m.gender || 'other'} ${formatDate(m.birthDate)} ${m.isLiving ? 'Living' : formatDate(m.deathDate)} ${lifespanText} ${escapeHtml(parentNames)} ${escapeHtml(spouseName)}
`; // 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 = ''; parent2Select.innerHTML = ''; spouseSelect.innerHTML = ''; 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 => `${escapeHtml(p.firstName)} ${escapeHtml(p.lastName)}`) .join(', ') || 'None recorded'; let spouseHtml = 'None recorded'; if (person.spouseId && memberMap.has(person.spouseId)) { const sp = memberMap.get(person.spouseId); spouseHtml = `${escapeHtml(sp.firstName)} ${escapeHtml(sp.lastName)}`; } // Children of this person const children = familyMembers.filter(m => (m.parents || []).includes(person.id)); const childrenHtml = children.length > 0 ? children.map(c => `${escapeHtml(c.firstName)} ${escapeHtml(c.lastName)}`).join(', ') : 'None recorded'; detailsModalBody.innerHTML = `
Gender
${person.gender || 'Not specified'}
Birth Date
${formatDate(person.birthDate) || '—'}
${!person.isLiving ? `
Death Date
${formatDate(person.deathDate) || '—'}
` : ''}
Lifespan / Status
${ageStr}
Parents
${parentNames}
Spouse / Partner
${spouseHtml}
Children
${childrenHtml}
${person.notes ? `
Notes & Biography
${escapeHtml(person.notes)}
` : ''} `; // 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, '''); } // --- App Start --- initTheme(); loadData(); renderTree(); renderRegistryTable(); setTimeout(fitToScreen, 100); })();