Roesstad/script.js

1453 lines
52 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);
setTimeout(() => drawRelationshipLines(memberMap), 60);
});
}
/**
* 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 += ` &nbsp;† ${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;
}
/**
* Get unscaled local coordinates of an element inside treeCanvas.
* Guaranteed to be zoom/pan invariant so SVG lines attach precisely to cards.
*/
function getElementCanvasBox(elem) {
if (!elem) return null;
const elemRect = elem.getBoundingClientRect();
const canvasRect = treeCanvas.getBoundingClientRect();
const scale = zoomScale || 1;
const left = (elemRect.left - canvasRect.left) / scale;
const right = (elemRect.right - canvasRect.left) / scale;
const top = (elemRect.top - canvasRect.top) / scale;
const bottom = (elemRect.bottom - canvasRect.top) / scale;
return {
left,
right,
top,
bottom,
centerX: (left + right) / 2,
centerY: (top + bottom) / 2,
width: right - left,
height: bottom - top
};
}
/**
* Draw SVG curved connecting branches between parents, spouses, and children
*/
function drawRelationshipLines(memberMap) {
treeSvg.innerHTML = '';
if (!memberMap) {
memberMap = new Map(familyMembers.map(m => [m.id, m]));
}
// Ensure SVG 1:1 pixel mapping without viewBox scaling distortion
treeSvg.removeAttribute('viewBox');
treeSvg.removeAttribute('width');
treeSvg.removeAttribute('height');
treeSvg.style.width = '100%';
treeSvg.style.height = '100%';
// 1. Draw horizontal connection line between married couples
const coupleContainers = document.querySelectorAll('.couple-container');
coupleContainers.forEach(coupleDiv => {
const cards = coupleDiv.querySelectorAll('.person-card');
if (cards.length >= 2) {
const box1 = getElementCanvasBox(cards[0]);
const box2 = getElementCanvasBox(cards[1]);
if (box1 && box2) {
const spousePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
spousePath.setAttribute('class', 'relation-line spouse');
spousePath.setAttribute('d', `M ${box1.right} ${box1.centerY} L ${box2.left} ${box2.centerY}`);
treeSvg.appendChild(spousePath);
}
}
});
// 2. 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);
});
// 3. Draw connections from each parent group down to their children
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]}`);
const box1 = getElementCanvasBox(card1);
const box2 = getElementCanvasBox(card2);
if (box1 && box2) {
originX = (box1.centerX + box2.centerX) / 2;
// Check if marriage symbol exists
const symbol = document.querySelector(`#couple_${parentIds[0]}_${parentIds[1]} .marriage-symbol`)
|| document.querySelector(`#couple_${parentIds[1]}_${parentIds[0]} .marriage-symbol`);
const symbolBox = getElementCanvasBox(symbol);
if (symbolBox) {
originX = symbolBox.centerX;
originY = symbolBox.bottom;
} else {
originY = Math.max(box1.bottom, box2.bottom);
}
} else if (box1 || box2) {
const single = box1 || box2;
originX = single.centerX;
originY = single.bottom;
}
} else if (parentIds.length === 1) {
const card = document.getElementById(`card_${parentIds[0]}`);
const box = getElementCanvasBox(card);
if (box) {
originX = box.centerX;
originY = box.bottom;
}
}
if (!originX || !originY) return;
// Collect valid child card coordinates
const childBoxes = [];
childrenIds.forEach(cid => {
const cCard = document.getElementById(`card_${cid}`);
const cBox = getElementCanvasBox(cCard);
if (cBox) {
childBoxes.push({ id: cid, box: cBox });
}
});
if (childBoxes.length === 0) return;
// Sort child cards left-to-right by centerX
childBoxes.sort((a, b) => a.box.centerX - b.box.centerX);
const minChildTop = Math.min(...childBoxes.map(c => c.box.top));
// Bus bar Y: midway between parent bottom and child top
const busY = originY + (minChildTop - originY) * 0.5;
// Vertical stem down from parent(s) to the 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);
if (childBoxes.length === 1) {
const cBox = childBoxes[0].box;
if (Math.abs(originX - cBox.centerX) < 3) {
const directDrop = document.createElementNS('http://www.w3.org/2000/svg', 'path');
directDrop.setAttribute('class', 'relation-line');
directDrop.setAttribute('d', `M ${originX} ${busY} L ${cBox.centerX} ${cBox.top}`);
treeSvg.appendChild(directDrop);
} else {
const singleBusPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
singleBusPath.setAttribute('class', 'relation-line');
singleBusPath.setAttribute('d', `M ${originX} ${busY} L ${cBox.centerX} ${busY} L ${cBox.centerX} ${cBox.top}`);
treeSvg.appendChild(singleBusPath);
}
} else {
// Multiple children
const minChildX = childBoxes[0].box.centerX;
const maxChildX = childBoxes[childBoxes.length - 1].box.centerX;
const busLeft = Math.min(minChildX, originX);
const busRight = Math.max(maxChildX, originX);
// Horizontal bus bar
const busPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
busPath.setAttribute('class', 'relation-line');
busPath.setAttribute('d', `M ${busLeft} ${busY} L ${busRight} ${busY}`);
treeSvg.appendChild(busPath);
// Vertical drops into each child
childBoxes.forEach(c => {
const dropPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
dropPath.setAttribute('class', 'relation-line');
dropPath.setAttribute('d', `M ${c.box.centerX} ${busY} L ${c.box.centerX} ${c.box.top}`);
treeSvg.appendChild(dropPath);
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', c.box.centerX);
dot.setAttribute('cy', busY);
dot.setAttribute('r', '3');
dot.setAttribute('class', 'relation-joint');
treeSvg.appendChild(dot);
});
// Dot at parent stem connection
const parentDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
parentDot.setAttribute('cx', originX);
parentDot.setAttribute('cy', busY);
parentDot.setAttribute('r', '3.5');
parentDot.setAttribute('class', 'relation-joint');
treeSvg.appendChild(parentDot);
}
});
}
// --- 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();
requestAnimationFrame(() => drawRelationshipLines());
}
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 box = getElementCanvasBox(card);
if (!box) return;
panX = viewportRect.width / 2 - box.centerX * zoomScale;
panY = viewportRect.height / 2 - box.centerY * 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// --- App Start ---
initTheme();
loadData();
renderTree();
renderRegistryTable();
setTimeout(fitToScreen, 100);
})();