diff --git a/script.js b/script.js
index 4eba4de..82a0b22 100644
--- a/script.js
+++ b/script.js
@@ -1,719 +1,1375 @@
+/**
+ * Family Tree Logger & Visual Auto-Sorting Tree Engine
+ */
-// Firebase Configuration
-const firebaseConfig = {
- apiKey: "AIzaSyD-E58BdRwFCHDmsgRG9-PEWzMaQoheJ-0",
- authDomain: "roesstad-9fd48.firebaseapp.com",
- projectId: "roesstad-9fd48",
- storageBucket: "roesstad-9fd48.firebasestorage.app",
- messagingSenderId: "704119736902",
- appId: "1:704119736902:web:bc47f2b6a1383de0479847"
-};
+(function () {
+ 'use strict';
-// Initialize Firebase
-firebase.initializeApp(firebaseConfig);
-const auth = firebase.auth();
-const db = firebase.firestore();
+ // --- Local Storage Keys ---
+ const STORAGE_KEY = 'roesstad_family_tree_data_v2';
+ const THEME_KEY = 'roesstad_family_tree_theme';
-// DOM Elements
-const loginModal = document.getElementById('login-modal');
-const loginForm = document.getElementById('login-form');
-const loginError = document.getElementById('login-error');
-const logoutBtn = document.getElementById('logoutBtn');
-const darkModeToggle = document.getElementById('darkModeToggle');
-const navBtns = document.querySelectorAll('.nav-btn');
-const tabContents = document.querySelectorAll('.tab-content');
-const memberModal = document.getElementById('member-modal');
-const addMemberBtn = document.getElementById('addMemberBtn');
-const closeModalBtns = document.querySelectorAll('.close-modal');
-const memberForm = document.getElementById('member-form');
+ // --- 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 currentUser = null;
-let familyData = [];
-// Global Tree State for Pan/Zoom
-let treeState = {
- scale: 1,
- panning: false,
- pointX: 0,
- pointY: 0,
- startX: 0,
- startY: 0
-};
+ // --- State ---
+ let familyMembers = [];
+ let selectedPersonId = null;
+ let tableSortColumn = 'birthDate';
+ let tableSortAsc = true;
-// --- Authentication ---
+ // Pan & Zoom State
+ let zoomScale = 1;
+ let panX = 0;
+ let panY = 0;
+ let isPanning = false;
+ let startX = 0;
+ let startY = 0;
-auth.onAuthStateChanged(user => {
- if (user) {
- currentUser = user;
- loginModal.style.display = 'none';
- logoutBtn.style.display = 'block';
- loadFamilyData();
- } else {
- currentUser = null;
- loginModal.style.display = 'flex';
- logoutBtn.style.display = 'none';
+ // --- 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);
}
-});
-loginForm.addEventListener('submit', (e) => {
- e.preventDefault();
- const email = document.getElementById('email').value;
- const password = document.getElementById('password').value;
+ function calculateAge(birthDateStr, deathDateStr, isLiving) {
+ if (!birthDateStr) return null;
+ const birth = new Date(birthDateStr);
+ if (isNaN(birth.getTime())) return null;
- auth.signInWithEmailAndPassword(email, password)
- .catch(error => {
- loginError.textContent = "Feil e-post eller passord.";
- console.error(error);
- });
-});
+ const end = (!isLiving && deathDateStr) ? new Date(deathDateStr) : new Date();
+ if (isNaN(end.getTime())) return null;
-logoutBtn.addEventListener('click', () => {
- auth.signOut();
-});
+ 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;
+ }
-// --- UI Logic ---
+ function formatDate(dateStr) {
+ if (!dateStr) return '';
+ const parts = dateStr.split('-');
+ if (parts.length === 3) {
+ return `${parts[0]}-${parts[1]}-${parts[2]}`;
+ }
+ return dateStr;
+ }
-// Dark Mode
-darkModeToggle.addEventListener('click', () => {
- document.body.classList.toggle('dark-mode');
- const isDark = document.body.classList.contains('dark-mode');
- darkModeToggle.textContent = isDark ? '🌙' : '☀️';
-});
+ function getYear(dateStr) {
+ if (!dateStr) return '';
+ return dateStr.split('-')[0] || '';
+ }
-// Geocoding
-document.getElementById('geocodeBtn').addEventListener('click', () => {
- const location = document.getElementById('location').value;
- if (!location) return;
+ // --- Storage & Data Initialization ---
- const geocoder = new google.maps.Geocoder();
- geocoder.geocode({ 'address': location }, (results, status) => {
- if (status === 'OK') {
- const lat = results[0].geometry.location.lat();
- const lng = results[0].geometry.location.lng();
-
- document.getElementById('lat').value = lat.toFixed(6);
- document.getElementById('lng').value = lng.toFixed(6);
+ 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 {
- alert('Kunne ikke finne koordinater for dette stedet: ' + status);
+ familyMembers = [...SAMPLE_FAMILY_DATA];
+ saveData();
}
- });
-});
-
-// --- Data Handling ---
-
-function loadFamilyData() {
- console.log("Loading family data...");
- db.collection('familyMembers').onSnapshot(snapshot => {
- console.log("Snapshot received. Docs:", snapshot.size);
- familyData = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
- populateDropdowns();
-
- // Refresh active view
- const activeTab = document.querySelector('.tab-content.active');
- if (activeTab && activeTab.id === 'tree-tab') renderTree();
- if (activeTab && activeTab.id === 'history-tab') renderHistory();
- if (activeTab && activeTab.id === 'map-tab') initMap();
- }, error => {
- console.error("Firestore Error:", error);
- const container = document.getElementById('family-cards-container');
- if (container) {
- container.innerHTML = `
`;
- }
- });
-}
-
-function populateDropdowns(excludeId = null) {
- const selects = ['motherId', 'fatherId', 'spouseId'];
- selects.forEach(id => {
- const select = document.getElementById(id);
- if (!select) return;
- select.innerHTML = '
';
-
- const sorted = [...familyData].sort((a, b) => (a.firstName || '').localeCompare(b.firstName || ''));
-
- sorted.forEach(m => {
- if (m.id === excludeId) return;
- const option = document.createElement('option');
- option.value = m.id;
- option.textContent = `${m.firstName} ${m.lastName} (${new Date(m.birthDate).getFullYear()})`;
- select.appendChild(option);
- });
- });
-}
-
-// Add/Edit Member
-memberForm.addEventListener('submit', (e) => {
- e.preventDefault();
- const id = document.getElementById('member-id').value;
-
- const data = {
- firstName: document.getElementById('firstName').value,
- lastName: document.getElementById('lastName').value,
- gender: document.querySelector('input[name="gender"]:checked').value,
- birthDate: document.getElementById('birthDate').value,
- deathDate: document.getElementById('deathDate').value,
- location: document.getElementById('location').value,
- lat: parseFloat(document.getElementById('lat').value) || null,
- lng: parseFloat(document.getElementById('lng').value) || null,
- motherId: document.getElementById('motherId').value || null,
- fatherId: document.getElementById('fatherId').value || null,
- spouseId: document.getElementById('spouseId').value || null,
- notes: document.getElementById('notes').value
- };
-
- if (id) {
- db.collection('familyMembers').doc(id).update(data)
- .then(() => {
- memberModal.style.display = 'none';
- memberForm.reset();
- });
- } else {
- db.collection('familyMembers').add(data)
- .then(() => {
- memberModal.style.display = 'none';
- memberForm.reset();
- });
- }
-});
-
-// Modal Actions
-addMemberBtn.addEventListener('click', () => {
- document.getElementById('modal-title').textContent = "Legg til familiemedlem";
- memberForm.reset();
- document.getElementById('member-id').value = '';
- populateDropdowns();
- memberModal.style.display = 'flex';
-});
-
-closeModalBtns.forEach(btn => {
- btn.addEventListener('click', () => {
- memberModal.style.display = 'none';
- loginModal.style.display = 'none';
- });
-});
-
-window.onclick = (e) => {
- if (e.target === memberModal) memberModal.style.display = 'none';
- if (e.target === loginModal) loginModal.style.display = 'none';
-};
-
-const deleteBtn = document.getElementById('deleteBtn');
-if (deleteBtn) {
- deleteBtn.addEventListener('click', () => {
- const id = document.getElementById('member-id').value;
- if (!id) return;
- if (confirm('Er du sikker på at du vil slette denne personen?')) {
- db.collection('familyMembers').doc(id).delete()
- .then(() => {
- memberModal.style.display = 'none';
- });
- }
- });
-}
-
-// --- Feature: Family Tree (Static Layout) ---
-
-// Feature: Family Tree (Static Layout) ---
-
-function updateTransform() {
- const treeContainer = document.getElementById('tree-container');
- if (treeContainer) {
- // Safety check for NaN
- if (isNaN(treeState.pointX)) treeState.pointX = 0;
- if (isNaN(treeState.pointY)) treeState.pointY = 0;
- if (isNaN(treeState.scale)) treeState.scale = 1;
-
- treeContainer.style.transform = `translate(${treeState.pointX}px, ${treeState.pointY}px) scale(${treeState.scale})`;
- console.log(`Transform updated: X=${treeState.pointX}, Y=${treeState.pointY}, Scale=${treeState.scale}`);
- }
-}
-
-function renderTree() {
- const container = document.getElementById('family-cards-container');
- const treeTab = document.getElementById('tree-tab');
-
- // Disable Native Scrolling, Enable Custom
- if (treeTab) {
- // Handled by CSS (overflow: hidden), but force styles if needed
- treeTab.style.overflow = 'hidden';
- treeTab.style.cursor = 'grab';
}
- if (!container) return;
- container.innerHTML = '';
-
- const svg = document.getElementById('relationship-lines');
- if (svg) svg.innerHTML = '';
-
- if (!familyData || familyData.length === 0) {
- container.innerHTML = '
Ingen familiemedlemmer funnet. Klikk på + for å legge til.
';
- return;
+ function saveData() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(familyMembers));
+ updateStats();
}
- try {
- const memberMap = new Map(familyData.map(m => [m.id, { ...m }]));
- const childrenMap = new Map();
- const partnerAdjacency = new Map();
+ 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)`;
+ }
- const addRelation = (id1, id2, type) => {
- if (!id1 || !id2) return;
- if (!partnerAdjacency.has(id1)) partnerAdjacency.set(id1, []);
- if (!partnerAdjacency.has(id2)) partnerAdjacency.set(id2, []);
+ // --- Generation & Auto-Sorting Tree Layout Engine ---
- if (!partnerAdjacency.get(id1).find(r => r.id === id2)) {
- partnerAdjacency.get(id1).push({ id: id2, type });
+ /**
+ * 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));
+ }
}
- if (!partnerAdjacency.get(id2).find(r => r.id === id1)) {
- partnerAdjacency.get(id2).push({ id: id1, type });
- }
- };
- familyData.forEach(m => {
- if (m.spouseId) addRelation(m.id, m.spouseId, 'spouse');
- if (m.motherId && m.fatherId) {
- // Parents match -> they are partners
- addRelation(m.motherId, m.fatherId, 'coparent');
- [m.motherId, m.fatherId].forEach(pId => {
- if (!childrenMap.has(pId)) childrenMap.set(pId, []);
- childrenMap.get(pId).push(m.id);
- });
- } else {
- // Single parent or partial
- [m.motherId, m.fatherId].forEach(pId => {
- if (pId && memberMap.has(pId)) {
- if (!childrenMap.has(pId)) childrenMap.set(pId, []);
- childrenMap.get(pId).push(m.id);
- }
- });
+ 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);
}
});
- const renderedSet = new Set();
- const placedNodes = [];
+ return genMap;
+ }
- const CARD_WIDTH = 220;
- const GAP_X = 50;
- const UNIT_GAP = 100;
- const GAP_Y = 250;
+ /**
+ * Auto-sort and build the visual tree hierarchy
+ */
+ function renderTree() {
+ treeNodesLayer.innerHTML = '';
+ treeSvg.innerHTML = '';
- const createCard = (member, x, y) => {
- if (!member) return;
- if (renderedSet.has(member.id)) return;
-
- const card = document.createElement('div');
- card.id = `card-${member.id}`;
- card.className = 'family-card';
- card.style.left = `${x}px`;
- card.style.top = `${y}px`;
-
- const genderIcon = member.gender === 'male' ? '👨' : (member.gender === 'female' ? '👩' : '👤');
- card.innerHTML = `
-
- ${new Date(member.birthDate).getFullYear()} - ${member.deathDate ? new Date(member.deathDate).getFullYear() : ''}
+ 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.
+
`;
- container.appendChild(card);
- card.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation(); // Stop pan
- openEditModal(member);
- });
- // Prevent drag start on card text/images
- card.addEventListener('mousedown', (e) => {
- e.stopPropagation();
- });
-
-
- placedNodes.push({ ...member, x, y, width: CARD_WIDTH, height: 100 });
- renderedSet.add(member.id);
- };
-
- const renderComponent = (rootId, startY) => {
- const component = new Set();
- const queue = [rootId];
- component.add(rootId);
-
- while (queue.length > 0) {
- const curr = queue.shift();
- const partners = partnerAdjacency.get(curr) || [];
- partners.forEach(p => {
- if (!component.has(p.id)) {
- component.add(p.id);
- queue.push(p.id);
- }
- });
- }
-
- const members = Array.from(component);
- let sortedIds = [];
-
- const pivot = members.find(mId => {
- const adj = partnerAdjacency.get(mId) || [];
- const hasSpouse = adj.some(a => a.type === 'spouse');
- const hasEx = adj.some(a => a.type === 'coparent');
- return hasSpouse && hasEx;
- });
-
- if (pivot) {
- const adj = partnerAdjacency.get(pivot) || [];
- const exes = adj.filter(a => a.type === 'coparent').map(a => a.id);
- const spouses = adj.filter(a => a.type === 'spouse').map(a => a.id);
- sortedIds = [...new Set([...exes, pivot, ...spouses])];
- } else {
- sortedIds = members.sort((a, b) => {
- const ma = memberMap.get(a);
- const mb = memberMap.get(b);
- return new Date(ma.birthDate) - new Date(mb.birthDate);
- });
- }
-
- const gapWidths = [];
- for (let i = 0; i < sortedIds.length - 1; i++) {
- const p1 = sortedIds[i];
- const p2 = sortedIds[i + 1];
-
- const c1 = childrenMap.get(p1) || [];
- const c2 = childrenMap.get(p2) || [];
- const common = c1.filter(id => c2.includes(id));
-
- let childrenBlockWidth = common.length * (CARD_WIDTH + GAP_X) - GAP_X;
- let gap = UNIT_GAP;
- if (childrenBlockWidth > CARD_WIDTH) {
- gap = Math.max(UNIT_GAP, childrenBlockWidth - CARD_WIDTH * 2 + 100);
- }
- gapWidths.push(gap);
- }
-
- // Initially layout from 0
- let currentX = 0;
-
- sortedIds.forEach((id, index) => {
- const member = memberMap.get(id);
- createCard(member, currentX, startY);
-
- if (index < sortedIds.length - 1) {
- const nextId = sortedIds[index + 1];
- const gap = gapWidths[index];
-
- const c1 = childrenMap.get(id) || [];
- const c2 = childrenMap.get(nextId) || [];
- const common = c1.filter(kid => c2.includes(kid));
-
- if (common.length > 0) {
- common.sort((a, b) => {
- const ma = memberMap.get(a);
- const mb = memberMap.get(b);
- return new Date(ma.birthDate) - new Date(mb.birthDate);
- });
-
- const gapCenterX = currentX + CARD_WIDTH + gap / 2;
- let totalChildrenWidth = common.length * CARD_WIDTH + (common.length - 1) * GAP_X;
- let childStartX = gapCenterX - totalChildrenWidth / 2;
-
- common.forEach(childId => {
- createCard(memberMap.get(childId), childStartX, startY + GAP_Y);
- childStartX += CARD_WIDTH + GAP_X;
- });
- }
- currentX += CARD_WIDTH + gap;
- }
-
- // --- Single / Orphaned Children ---
- const myChildren = childrenMap.get(id) || [];
- const unrenderedChildren = myChildren.filter(kidId => !renderedSet.has(kidId));
-
- if (unrenderedChildren.length > 0) {
- unrenderedChildren.sort((a, b) => new Date(memberMap.get(a).birthDate) - new Date(memberMap.get(b).birthDate));
-
- let childX = currentX - ((unrenderedChildren.length * CARD_WIDTH) / 2);
- if (index > 0) childX = Math.max(childX, currentX);
-
- unrenderedChildren.forEach(kidId => {
- createCard(memberMap.get(kidId), childX, startY + GAP_Y);
- childX += CARD_WIDTH + GAP_X;
- });
- }
- });
- return currentX;
- };
-
- const sortedMembers = Array.from(memberMap.values()).sort((a, b) => new Date(a.birthDate) - new Date(b.birthDate));
-
- let rootY = 100;
-
- sortedMembers.forEach(m => {
- if (!renderedSet.has(m.id)) {
- const isChild = (m.motherId && memberMap.has(m.motherId)) || (m.fatherId && memberMap.has(m.fatherId));
- if (!isChild) {
- renderComponent(m.id, rootY);
- }
- }
- });
-
- // 3. Infinite Canvas: Remove fixed limits
- // We do simple auto-center ONCE on load, but no forced container clipping
- if (placedNodes.length > 0) {
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
- placedNodes.forEach(n => {
- if (n.x < minX) minX = n.x;
- if (n.x + n.width > maxX) maxX = n.x + n.width;
- if (n.y < minY) minY = n.y;
- if (n.y + n.height > maxY) maxY = n.y + n.height;
- });
-
- // Center Viewport
- const treeWidth = maxX - minX;
- const treeHeight = maxY - minY;
- const screenW = window.innerWidth;
- const screenH = window.innerHeight;
-
- treeState.pointX = (screenW - treeWidth) / 2 - minX;
- treeState.pointY = (screenH - treeHeight) / 2 - minY;
- treeState.pointY = Math.max(50, treeState.pointY); // Top padding
-
- // Ensure not NaN
- if (isNaN(treeState.pointX)) treeState.pointX = 0;
- if (isNaN(treeState.pointY)) treeState.pointY = 0;
-
- console.log("Calculated Initial Center:", treeState);
- updateTransform();
- }
-
- // Draw Lines
- const edges = [];
- const nodeLookup = new Map(placedNodes.map(n => [n.id, n]));
-
- placedNodes.forEach(node => {
- const partners = partnerAdjacency.get(node.id) || [];
- partners.forEach(rel => {
- if (node.id < rel.id && nodeLookup.has(rel.id)) {
- edges.push({ source: node, target: nodeLookup.get(rel.id), type: rel.type });
- }
- });
- });
-
- drawStaticLines(placedNodes, edges);
-
- } catch (err) {
- console.error("Render Error:", err);
- container.innerHTML = `
En feil oppstod under visning av treet:
${err.message}
`;
- }
-}
-
-function drawStaticLines(nodes, edges) {
- const svg = document.getElementById('relationship-lines');
- if (!svg) return;
-
- let minX = Infinity, maxX = -Infinity, maxY = 0;
- nodes.forEach(n => {
- if (n.x < minX) minX = n.x;
- if (n.x + n.width > maxX) maxX = n.x + n.width;
- if (n.y + n.height > maxY) maxY = n.y + n.height;
- });
-
- // SVG needs to be big enough to cover everything
- // We can make it huge for infinite canvas feel
- svg.setAttribute('width', Math.max(5000, maxX + 1000));
- svg.setAttribute('height', Math.max(5000, maxY + 1000));
-
- let html = '';
- const nodeLookup = new Map(nodes.map(n => [n.id, n]));
-
- // 1. Draw Partner Lines
- edges.forEach(edge => {
- if (edge.type === 'spouse' || edge.type === 'coparent') {
- const s = edge.source;
- const t = edge.target;
- const x1 = s.x + s.width / 2;
- const y1 = s.y + 50;
- const x2 = t.x + t.width / 2;
- const y2 = t.y + 50;
-
- const strokeDash = edge.type === 'coparent' ? 'stroke-dasharray="5,5"' : '';
- html += `
`;
- }
- });
-
- // 2. Draw Parent-Child Lines (Bracket Style)
- const groups = new Map();
-
- nodes.forEach(node => {
- if (node.motherId || node.fatherId) {
- const parents = [node.motherId, node.fatherId].filter(x => x).sort().join('|');
- if (parents) {
- if (!groups.has(parents)) groups.set(parents, []);
- groups.get(parents).push(node);
- }
- }
- });
-
- groups.forEach((children, key) => {
- const [p1Id, p2Id] = key.split('|');
- const p1 = nodeLookup.get(p1Id);
- const p2 = nodeLookup.get(p2Id);
-
- let startX, startY;
-
- if (p1 && p2) {
- startX = (p1.x + p1.width / 2 + p2.x + p2.width / 2) / 2;
- startY = p1.y + 50;
- } else if (p1) {
- startX = p1.x + p1.width / 2;
- startY = p1.y + 100;
- } else if (p2) {
- startX = p2.x + p2.width / 2;
- startY = p2.y + 100;
- } else {
return;
}
- const dropHeight = 80;
- const bracketY = startY + dropHeight;
+ const genMap = computeGenerations(familyMembers);
+ const memberMap = new Map(familyMembers.map(m => [m.id, m]));
- html += `
`;
-
- let minChildX = Infinity;
- let maxChildX = -Infinity;
- const childTopY = children[0].y;
-
- children.forEach(child => {
- const cx = child.x + child.width / 2;
- if (cx < minChildX) minChildX = cx;
- if (cx > maxChildX) maxChildX = cx;
-
- html += `
`;
+ // 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);
});
- if (children.length > 1) {
- html += `
`;
- } else {
- const childX = children[0].x + children[0].width / 2;
- if (Math.abs(startX - childX) > 1) {
- html += `
`;
- }
- }
- });
+ // Sorted generation levels (0, 1, 2, 3...)
+ const sortedGenLevels = Array.from(generations.keys()).sort((a, b) => a - b);
- svg.innerHTML = html;
-}
+ // Process each generation level
+ sortedGenLevels.forEach(genLevel => {
+ const membersInGen = generations.get(genLevel);
-// --- Feature: Family Tree Navigation (Pan/Zoom) ---
+ // 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);
+ });
-function setupPanZoom() {
- const treeTab = document.getElementById('tree-tab');
- const treeContainer = document.getElementById('tree-container');
+ // Container row for this generation
+ const rowDiv = document.createElement('div');
+ rowDiv.className = 'generation-row';
+ rowDiv.dataset.gen = genLevel;
- if (!treeTab || !treeContainer) {
- console.error("SetupPanZoom: Elements not found");
- return;
+ // 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);
+ });
}
- console.log("SetupPanZoom: Initializing...");
+ /**
+ * 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;
- // Force styles
- treeTab.style.overflow = 'hidden';
- treeTab.style.cursor = 'grab';
- treeTab.style.width = '100%';
- treeTab.style.height = '100%';
- treeTab.style.userSelect = 'none'; // Improve drag feel
- treeContainer.style.transformOrigin = '0 0';
+ const age = calculateAge(person.birthDate, person.deathDate, person.isLiving);
+ const bYear = getYear(person.birthDate) || '?';
+ const dYear = getYear(person.deathDate);
- // Zoom (Wheel)
- treeTab.addEventListener('wheel', (e) => {
- e.preventDefault();
-
- // Safety against NaN
- if (isNaN(treeState.scale)) treeState.scale = 1;
- if (isNaN(treeState.pointX)) treeState.pointX = 0;
- if (isNaN(treeState.pointY)) treeState.pointY = 0;
-
- if (e.ctrlKey) {
- const xs = (e.clientX - treeState.pointX) / treeState.scale;
- const ys = (e.clientY - treeState.pointY) / treeState.scale;
- const delta = -e.deltaY;
- (delta > 0) ? (treeState.scale *= 1.1) : (treeState.scale /= 1.1);
- treeState.scale = Math.min(Math.max(0.1, treeState.scale), 5);
- treeState.pointX = e.clientX - xs * treeState.scale;
- treeState.pointY = e.clientY - ys * treeState.scale;
- } else {
- // Scroll Pan
- treeState.pointX -= e.deltaX;
- treeState.pointY -= e.deltaY;
+ let dateDisplay = `* ${bYear}`;
+ if (!person.isLiving && dYear) {
+ dateDisplay += ` † ${dYear}`;
}
- updateTransform();
- });
- // Pan (Mouse)
- treeTab.addEventListener('mousedown', (e) => {
- // Allow clicks on buttons/inputs
- if (e.target.closest('button') || e.target.closest('input')) return;
-
- // Stop if clicking a card (handled in card click)
- if (e.target.closest('.family-card')) return;
-
- console.log("MouseDown on treeTab");
- e.preventDefault();
-
- treeState.startX = e.clientX - treeState.pointX;
- treeState.startY = e.clientY - treeState.pointY;
- treeState.panning = true;
- treeTab.style.cursor = 'grabbing';
- });
-
- // Attach to WINDOW/DOCUMENT to handle drags that go outside the element
- window.addEventListener('mousemove', (e) => {
- if (!treeState.panning) return;
- e.preventDefault();
- treeState.pointX = e.clientX - treeState.startX;
- treeState.pointY = e.clientY - treeState.startY;
- updateTransform();
- });
-
- const stopPan = () => {
- if (treeState.panning) {
- console.log("MouseUp: Stopping pan");
- treeState.panning = false;
- treeTab.style.cursor = 'grab';
+ let ageBadge = '';
+ if (age !== null) {
+ ageBadge = person.isLiving ? `${age} yrs (Living)` : `Died at ${age} yrs`;
}
- };
- window.addEventListener('mouseup', stopPan);
- // Also stop on mouseleave if desired, but window mouseup usually catches it
- treeTab.addEventListener('mouseleave', stopPan);
+ card.innerHTML = `
+
+
${escapeHtml(person.firstName)} ${escapeHtml(person.lastName)}
+
+ ${person.isLiving ? 'Living' : 'Deceased'}
+
+
- // Global click logger REMOVED
-}
+
+
+ 📅
+ ${dateDisplay}
+
+ ${ageBadge ? `
${ageBadge}
` : ''}
+
-// Initialize PanZoom when DOM is ready
-document.addEventListener('DOMContentLoaded', () => {
- setupPanZoom();
-});
+
+
+
+
+ `;
-// Tabs
-navBtns.forEach(btn => {
- btn.addEventListener('click', () => {
- navBtns.forEach(b => b.classList.remove('active'));
- tabContents.forEach(c => c.classList.remove('active'));
+ // Card Click opens details modal
+ card.addEventListener('click', (e) => {
+ if (e.target.closest('.card-action-btn')) return;
+ openDetailsModal(person.id);
+ });
- btn.classList.add('active');
- const tabId = btn.getAttribute('data-tab');
- document.getElementById(tabId).classList.add('active');
+ // Quick action buttons
+ card.querySelector('.action-add-child').addEventListener('click', (e) => {
+ e.stopPropagation();
+ openAddPersonModal({ parent1Id: person.id });
+ });
- if (tabId === 'tree-tab') {
- renderTree();
- } else if (tabId === 'map-tab') {
- if (typeof google !== 'undefined' && typeof google.maps !== 'undefined') {
- initMap();
- } else {
- console.warn("Google Maps API not loaded. Map will not render.");
+ 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: []
+ });
}
- } else if (tabId === 'history-tab') {
- renderHistory();
+ 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);
+
+})();
diff --git a/style.css b/style.css
index cd82bcb..65141c5 100644
--- a/style.css
+++ b/style.css
@@ -1,407 +1,1071 @@
+/* ==========================================================================
+ Family Tree Logger - Modern Design System
+ ========================================================================== */
+
:root {
- /* Røsstad Palette */
- --color-forest: #1a472a;
- --color-sea: #0f2b3e;
- --color-moss: #88a090;
- --color-bg-light: #f4f7f5;
- --color-bg-dark: #121212;
- --color-text-light: #ffffff;
- --color-text-dark: #333333;
- --glass-bg: rgba(255, 255, 255, 0.1);
- --glass-border: rgba(255, 255, 255, 0.2);
- --shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
+ /* Color Palette - Dark Theme (Default) */
+ --bg-main: #0b0f17;
+ --bg-surface: #131b2e;
+ --bg-surface-elevated: #1a243d;
+ --bg-card: rgba(22, 31, 53, 0.85);
+ --bg-glass: rgba(15, 23, 42, 0.75);
+
+ --border-color: rgba(255, 255, 255, 0.08);
+ --border-hover: rgba(255, 255, 255, 0.2);
+ --border-highlight: #10b981;
+
+ --text-primary: #f1f5f9;
+ --text-secondary: #94a3b8;
+ --text-muted: #64748b;
+
+ --primary: #10b981;
+ --primary-hover: #059669;
+ --primary-glow: rgba(16, 185, 129, 0.25);
+
+ --accent-blue: #38bdf8;
+ --accent-rose: #f43f5e;
+ --accent-purple: #c084fc;
+ --accent-amber: #fbbf24;
+
+ --danger: #ef4444;
+ --danger-bg: rgba(239, 68, 68, 0.15);
+
+ --grid-dot: rgba(255, 255, 255, 0.06);
+ --tree-line: #3b82f6;
+ --tree-line-spouse: #f43f5e;
+
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 16px;
+ --radius-full: 9999px;
+
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
+ --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.35);
+ --shadow-lg: 0 12px 36px rgba(0, 0, 0, 0.5);
+
+ --font-heading: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
}
-body {
+/* Light Theme Variables */
+body.light-theme {
+ --bg-main: #f8fafc;
+ --bg-surface: #ffffff;
+ --bg-surface-elevated: #f1f5f9;
+ --bg-card: rgba(255, 255, 255, 0.95);
+ --bg-glass: rgba(255, 255, 255, 0.85);
+
+ --border-color: rgba(0, 0, 0, 0.08);
+ --border-hover: rgba(0, 0, 0, 0.2);
+ --border-highlight: #059669;
+
+ --text-primary: #0f172a;
+ --text-secondary: #475569;
+ --text-muted: #94a3b8;
+
+ --primary: #059669;
+ --primary-hover: #047857;
+ --primary-glow: rgba(5, 150, 105, 0.2);
+
+ --grid-dot: rgba(0, 0, 0, 0.06);
+ --tree-line: #2563eb;
+ --tree-line-spouse: #e11d48;
+
+ --shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.12);
+}
+
+/* Base Reset & Layout */
+* {
+ box-sizing: border-box;
margin: 0;
- font-family: 'Outfit', 'Segoe UI', sans-serif;
- background-color: var(--color-bg-light);
- color: var(--color-text-dark);
- transition: background-color 0.3s, color 0.3s;
- overflow: hidden;
- /* Prevent body scroll, handle in containers */
+ padding: 0;
}
-body.dark-mode {
- background-color: var(--color-bg-dark);
- color: var(--color-text-light);
-}
-
-/* Glassmorphism Utilities */
-.glass {
- background: var(--glass-bg);
- backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
- border: 1px solid var(--glass-border);
-}
-
-.glass-panel {
- background: rgba(255, 255, 255, 0.05);
- /* Slightly more opaque */
- backdrop-filter: blur(16px);
- -webkit-backdrop-filter: blur(16px);
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: var(--shadow);
- border-radius: 16px;
-}
-
-/* Navigation */
-.top-nav {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 1rem 2rem;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- z-index: 1000;
-}
-
-.nav-brand {
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--color-forest);
-}
-
-body.dark-mode .nav-brand {
- color: var(--color-moss);
-}
-
-.nav-tabs {
- display: flex;
- gap: 1rem;
-}
-
-.nav-btn {
- background: none;
- border: none;
- padding: 0.5rem 1rem;
- font-size: 1rem;
- cursor: pointer;
- color: inherit;
- border-radius: 8px;
- transition: background 0.2s;
-}
-
-.nav-btn.active {
- background: var(--color-forest);
- color: white;
-}
-
-.nav-controls {
- display: flex;
- gap: 1rem;
- align-items: center;
-}
-
-.icon-btn {
- background: none;
- border: none;
- font-size: 1.2rem;
- cursor: pointer;
- color: inherit;
-}
-
-.text-btn {
- background: none;
- border: 1px solid currentColor;
- padding: 0.4rem 0.8rem;
- border-radius: 4px;
- cursor: pointer;
- color: inherit;
-}
-
-/* Main Content */
-#main-content {
- margin-top: 80px;
- /* Space for nav */
- height: calc(100vh - 80px);
- position: relative;
-}
-
-.tab-content {
- display: none;
- height: 100%;
+html, body {
width: 100%;
+ height: 100%;
+ overflow: hidden;
+ font-family: var(--font-body);
+ background-color: var(--bg-main);
+ color: var(--text-primary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
-.tab-content.active {
+/* Top App Header */
+.app-header {
+ height: 64px;
+ background: var(--bg-glass);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1.5rem;
+ position: relative;
+ z-index: 50;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.brand-icon {
+ width: 38px;
+ height: 38px;
+ border-radius: var(--radius-md);
+ background: linear-gradient(135deg, var(--primary), #0284c7);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px var(--primary-glow);
+}
+
+.brand-title {
+ font-family: var(--font-heading);
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: -0.02em;
+}
+
+.brand-subtitle {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+/* View Mode Switcher */
+.view-toggle-group {
+ display: flex;
+ background: var(--bg-surface-elevated);
+ border-radius: var(--radius-full);
+ padding: 3px;
+ border: 1px solid var(--border-color);
+}
+
+.toggle-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: transparent;
+ border: none;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ border-radius: var(--radius-full);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.toggle-btn:hover {
+ color: var(--text-primary);
+}
+
+.toggle-btn.active {
+ background: var(--primary);
+ color: #ffffff;
+ box-shadow: 0 2px 8px var(--primary-glow);
+}
+
+/* Header Right Actions */
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.search-box {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.search-icon {
+ position: absolute;
+ left: 0.75rem;
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+.search-box input {
+ background: var(--bg-surface-elevated);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-full);
+ padding: 0.45rem 2rem 0.45rem 2.2rem;
+ font-size: 0.825rem;
+ color: var(--text-primary);
+ width: 220px;
+ transition: all 0.2s ease;
+}
+
+.search-box input:focus {
+ outline: none;
+ border-color: var(--primary);
+ width: 280px;
+ box-shadow: 0 0 0 3px var(--primary-glow);
+}
+
+.clear-search-btn {
+ position: absolute;
+ right: 0.6rem;
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+ display: none;
+}
+
+.clear-search-btn.active {
display: block;
}
-/* Tree View */
-#tree-container {
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ font-family: var(--font-body);
+ font-size: 0.825rem;
+ font-weight: 600;
+ padding: 0.5rem 1rem;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: all 0.2s ease;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: #ffffff;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ box-shadow: 0 4px 12px var(--primary-glow);
+}
+
+.btn-secondary {
+ background: var(--bg-surface-elevated);
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-surface);
+ border-color: var(--border-hover);
+}
+
+.btn.icon-only {
+ padding: 0.5rem;
+ border-radius: var(--radius-full);
+ width: 36px;
+ height: 36px;
+}
+
+.btn-danger-outline {
+ background: transparent;
+ color: var(--danger);
+ border-color: var(--danger);
+}
+
+.btn-danger-outline:hover {
+ background: var(--danger-bg);
+}
+
+/* Dropdown Menu */
+.dropdown {
+ position: relative;
+}
+
+.dropdown-menu {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ padding: 0.35rem;
+ min-width: 190px;
+ z-index: 100;
+ display: none;
+ flex-direction: column;
+}
+
+.dropdown-menu.show {
+ display: flex;
+}
+
+.dropdown-item {
+ background: transparent;
+ border: none;
+ text-align: left;
+ padding: 0.55rem 0.85rem;
+ font-size: 0.825rem;
+ color: var(--text-primary);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.dropdown-item:hover {
+ background: var(--bg-surface-elevated);
+}
+
+.dropdown-item.danger {
+ color: var(--danger);
+}
+
+.dropdown-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 0.3rem 0;
+}
+
+/* Main Viewport Container */
+.main-viewport {
+ width: 100%;
+ height: calc(100vh - 64px);
+ position: relative;
+ overflow: hidden;
+}
+
+.view-panel {
width: 100%;
height: 100%;
+ display: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.view-panel.active {
+ display: block;
+}
+
+/* Tree Viewport & Canvas */
+.tree-viewport {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+ cursor: grab;
+ user-select: none;
+ background-color: var(--bg-main);
+ background-image: radial-gradient(circle, var(--grid-dot) 1.5px, transparent 1.5px);
+ background-size: 28px 28px;
+}
+
+.tree-viewport.panning {
+ cursor: grabbing;
+}
+
+.tree-canvas {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
- /* Zoom from top-left */
- cursor: grab;
- pointer-events: none;
- /* Let clicks pass to #tree-tab */
+ will-change: transform;
+ pointer-events: auto;
}
-#tree-container:active {
- cursor: grabbing;
-}
-
-#relationship-lines {
- position: absolute;
- top: 0;
- left: 0;
- pointer-events: none;
- /* Let clicks pass through to cards */
-}
-
-#family-cards-container {
+/* SVG Connection Lines */
+.tree-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
- /* Let clicks pass to #tree-tab */
+ overflow: visible;
+ z-index: 1;
}
-.family-card {
+.tree-svg path.relation-line {
+ fill: none;
+ stroke: var(--tree-line);
+ stroke-width: 2.5px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ opacity: 0.75;
+ transition: stroke 0.2s, stroke-width 0.2s, opacity 0.2s;
+}
+
+.tree-svg path.relation-line.spouse {
+ stroke: var(--tree-line-spouse);
+ stroke-width: 2.5px;
+ stroke-dasharray: 4 4;
+}
+
+.tree-svg path.relation-line.highlighted {
+ stroke: var(--accent-amber);
+ stroke-width: 3.5px;
+ opacity: 1;
+}
+
+/* Tree Nodes Layer */
+.tree-nodes-layer {
+ position: relative;
+ z-index: 2;
+ padding: 60px;
+ min-width: 1000px;
+ min-height: 800px;
+}
+
+/* Generation Tier Layout */
+.generation-row {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 40px;
+ margin-bottom: 90px;
+ position: relative;
+}
+
+.generation-label-tag {
position: absolute;
- width: 220px;
- height: 100px;
- background: var(--glass-bg);
- backdrop-filter: blur(8px);
- border: 1px solid var(--color-moss);
- border-radius: 12px;
- padding: 10px;
+ left: 20px;
+ top: -24px;
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ background: var(--bg-surface-elevated);
+ padding: 3px 10px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--border-color);
+}
+
+/* Family Units */
+.family-unit {
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: transform 0.2s, box-shadow 0.2s;
- color: inherit;
- pointer-events: auto;
- /* Re-enable clicking for cards */
+ gap: 16px;
}
-.family-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
- background: rgba(255, 255, 255, 0.15);
+.couple-container {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ position: relative;
}
-.card-avatar {
- font-size: 2rem;
- margin-bottom: 5px;
-}
-
-.card-name {
- font-weight: 600;
- font-size: 1.1rem;
-}
-
-.card-dates {
- font-size: 0.8rem;
- opacity: 0.8;
-}
-
-/* FAB */
-.fab {
- position: fixed;
- bottom: 2rem;
- right: 2rem;
- width: 60px;
- height: 60px;
- border-radius: 50%;
- background: var(--color-forest);
- color: white;
- border: none;
- font-size: 2rem;
- cursor: pointer;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+.marriage-symbol {
+ width: 24px;
+ height: 24px;
+ background: var(--bg-surface-elevated);
+ border: 1px solid var(--tree-line-spouse);
+ color: var(--accent-rose);
+ border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
- transition: transform 0.2s;
+ font-size: 0.7rem;
+ box-shadow: var(--shadow-sm);
+ z-index: 5;
}
-.fab:hover {
- transform: scale(1.1);
-}
-
-/* Map */
-#map {
- width: 100%;
- height: 100%;
-}
-
-/* History */
-.history-container {
- max-width: 800px;
- margin: 0 auto;
- padding: 2rem;
- overflow-y: auto;
- height: 100%;
-}
-
-#history-list {
- list-style: none;
- padding: 0;
- border-left: 2px solid var(--color-moss);
-}
-
-.history-item {
- margin-bottom: 1.5rem;
- padding-left: 1.5rem;
+/* Person Tree Card */
+.person-card {
+ width: 215px;
+ min-height: 110px;
+ background: var(--bg-card);
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 0.85rem 1rem;
+ box-shadow: var(--shadow-md);
+ cursor: pointer;
position: relative;
+ transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
}
-.history-item::before {
- content: '';
+.person-card:hover {
+ transform: translateY(-4px);
+ border-color: var(--primary);
+ box-shadow: var(--shadow-lg), 0 0 20px var(--primary-glow);
+}
+
+.person-card.male {
+ border-left: 4px solid var(--accent-blue);
+}
+
+.person-card.female {
+ border-left: 4px solid var(--accent-rose);
+}
+
+.person-card.other {
+ border-left: 4px solid var(--accent-purple);
+}
+
+.person-card.selected {
+ border-color: var(--accent-amber) !important;
+ box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.3), var(--shadow-lg);
+}
+
+.person-card.search-match {
+ animation: pulseHighlight 1.5s infinite;
+}
+
+@keyframes pulseHighlight {
+ 0%, 100% {
+ box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.4), var(--shadow-lg);
+ }
+ 50% {
+ box-shadow: 0 0 0 8px rgba(16, 185, 129, 0.8), var(--shadow-lg);
+ }
+}
+
+.card-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.card-name {
+ font-family: var(--font-heading);
+ font-size: 0.95rem;
+ font-weight: 600;
+ line-height: 1.25;
+ color: var(--text-primary);
+ word-break: break-word;
+}
+
+.status-indicator {
+ font-size: 0.65rem;
+ padding: 2px 7px;
+ border-radius: var(--radius-full);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.status-indicator.living {
+ background: rgba(16, 185, 129, 0.18);
+ color: var(--primary);
+}
+
+.status-indicator.deceased {
+ background: rgba(148, 163, 184, 0.15);
+ color: var(--text-secondary);
+}
+
+.card-dates {
+ margin-top: 0.4rem;
+ font-size: 0.775rem;
+ color: var(--text-secondary);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.date-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.date-symbol {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
+
+.card-lifespan-badge {
+ margin-top: 0.4rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: var(--text-muted);
+}
+
+/* Card Quick Hover Actions */
+.card-quick-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ margin-top: 0.5rem;
+ padding-top: 0.4rem;
+ border-top: 1px solid var(--border-color);
+ opacity: 0.6;
+ transition: opacity 0.2s;
+}
+
+.person-card:hover .card-quick-actions {
+ opacity: 1;
+}
+
+.card-action-btn {
+ background: var(--bg-surface-elevated);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ border-radius: var(--radius-sm);
+ font-size: 0.7rem;
+ padding: 2px 6px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.card-action-btn:hover {
+ background: var(--primary);
+ color: #fff;
+ border-color: var(--primary);
+}
+
+/* Canvas Floating Controls */
+.canvas-controls {
position: absolute;
- left: -6px;
- top: 6px;
- width: 10px;
- height: 10px;
- background: var(--color-forest);
- border-radius: 50%;
+ bottom: 24px;
+ right: 24px;
+ background: var(--bg-glass);
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-full);
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ box-shadow: var(--shadow-lg);
+ z-index: 10;
}
-.history-year {
- font-weight: bold;
- color: var(--color-moss);
- margin-right: 0.5rem;
+.control-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-primary);
+ width: 34px;
+ height: 34px;
+ border-radius: var(--radius-full);
+ font-size: 1.1rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.control-btn:hover {
+ background: var(--bg-surface-elevated);
+}
+
+.control-btn.auto-sort-btn {
+ width: auto;
+ padding: 0 12px;
+ font-size: 0.775rem;
+ gap: 6px;
+}
+
+.control-divider {
+ width: 1px;
+ height: 18px;
+ background: var(--border-color);
+ margin: 0 2px;
+}
+
+.canvas-hint {
+ position: absolute;
+ bottom: 24px;
+ left: 24px;
+ background: var(--bg-glass);
+ backdrop-filter: blur(10px);
+ border: 1px solid var(--border-color);
+ padding: 6px 14px;
+ border-radius: var(--radius-full);
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ z-index: 10;
+ pointer-events: none;
+}
+
+/* Registry List View Panel */
+.list-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2.5rem 1.5rem;
+ height: 100%;
+ overflow-y: auto;
+}
+
+.list-header-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1.5rem;
+}
+
+.list-header-row h2 {
+ font-family: var(--font-heading);
+ font-size: 1.6rem;
+ font-weight: 700;
+}
+
+.table-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
+ overflow: hidden;
+}
+
+.table-responsive {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.registry-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ font-size: 0.85rem;
+}
+
+.registry-table th {
+ background: var(--bg-surface-elevated);
+ padding: 0.85rem 1rem;
+ color: var(--text-secondary);
+ font-weight: 600;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.registry-table th:hover {
+ color: var(--text-primary);
+}
+
+.sort-indicator {
+ font-size: 0.75rem;
+ margin-left: 4px;
+ opacity: 0.5;
+}
+
+.registry-table td {
+ padding: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ color: var(--text-primary);
+ vertical-align: middle;
+}
+
+.registry-table tr:hover td {
+ background: var(--bg-surface-elevated);
+}
+
+.table-person-name {
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+.gender-tag {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ font-size: 0.725rem;
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.gender-tag.male {
+ background: rgba(56, 189, 248, 0.15);
+ color: var(--accent-blue);
+}
+
+.gender-tag.female {
+ background: rgba(244, 63, 94, 0.15);
+ color: var(--accent-rose);
+}
+
+.gender-tag.other {
+ background: rgba(192, 132, 252, 0.15);
+ color: var(--accent-purple);
}
/* Modals */
-.modal {
- display: none;
- /* Hidden by default */
+.modal-backdrop {
position: fixed;
top: 0;
left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(4px);
- z-index: 2000;
- justify-content: center;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(6px);
+ display: flex;
align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+ animation: fadeIn 0.2s ease;
}
-.modal-content {
- width: 90%;
- max-width: 500px;
- padding: 2rem;
- position: relative;
- max-height: 90vh;
- overflow-y: auto;
+.modal-dialog {
+ width: 100%;
+ max-width: 520px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+ animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
-.close-modal {
- position: absolute;
- top: 1rem;
- right: 1rem;
+.modal-dialog-sm {
+ max-width: 440px;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from { transform: translateY(20px) scale(0.98); opacity: 0; }
+ to { transform: translateY(0) scale(1); opacity: 1; }
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem 1.5rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.modal-header h2, .modal-header h3 {
+ font-family: var(--font-heading);
+ font-size: 1.25rem;
+ font-weight: 700;
+}
+
+.close-btn {
+ background: transparent;
+ border: none;
font-size: 1.5rem;
+ color: var(--text-muted);
cursor: pointer;
+ line-height: 1;
+ transition: color 0.15s;
}
-/* Forms */
-.form-group {
- margin-bottom: 1rem;
+.close-btn:hover {
+ color: var(--text-primary);
+}
+
+.person-form, .modal-body {
+ padding: 1.5rem;
}
.form-row {
display: flex;
gap: 1rem;
+ margin-bottom: 1.1rem;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin-bottom: 1.1rem;
}
.form-row .form-group {
+ margin-bottom: 0;
+}
+
+.flex-1 {
flex: 1;
}
-label {
- display: block;
- margin-bottom: 0.5rem;
- font-size: 0.9rem;
- opacity: 0.9;
+.form-group label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-secondary);
}
-input,
-select,
-textarea {
- width: 100%;
- padding: 0.8rem;
- border-radius: 8px;
- border: 1px solid rgba(255, 255, 255, 0.2);
- background: rgba(0, 0, 0, 0.2);
- color: inherit;
- font-family: inherit;
- box-sizing: border-box;
- /* Fix padding issues */
+.form-group label .req {
+ color: var(--danger);
}
-input:focus,
-select:focus,
-textarea:focus {
+.form-group input[type="text"],
+.form-group input[type="date"],
+.form-group select,
+.form-group textarea {
+ background: var(--bg-surface-elevated);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 0.6rem 0.85rem;
+ color: var(--text-primary);
+ font-family: var(--font-body);
+ font-size: 0.875rem;
+ transition: all 0.2s;
+}
+
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
outline: none;
- border-color: var(--color-moss);
- background: rgba(0, 0, 0, 0.3);
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px var(--primary-glow);
}
-.primary-btn {
- width: 100%;
- padding: 1rem;
- background: var(--color-forest);
- color: white;
- border: none;
- border-radius: 8px;
- font-size: 1rem;
- cursor: pointer;
- margin-top: 1rem;
+.form-divider {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0.4rem;
+ margin: 1.25rem 0 1rem 0;
}
-.primary-btn:hover {
- background: #235c38;
-}
-
-.error-msg {
- color: #ff6b6b;
- margin-top: 1rem;
- text-align: center;
-}
-
-/* Radio Group */
-.radio-group {
- display: flex;
- gap: 1.5rem;
-}
-
-.radio-label {
+.custom-checkbox {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: 0.6rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+ margin-top: 0.5rem;
+}
+
+.custom-checkbox input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--primary);
cursor: pointer;
}
-input[type="radio"] {
- width: auto;
- margin: 0;
+.modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid var(--border-color);
+ margin-top: 0.5rem;
}
-#tree-tab {
- overflow: hidden;
- /* Infinite canvas, no native scroll */
- position: relative;
- cursor: grab;
- width: 100%;
- height: 100%;
- user-select: none;
- /* Prevent text selection during drag */
+.modal-footer.justify-between {
+ justify-content: space-between;
}
-#tree-tab:active {
- cursor: grabbing;
+.footer-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+/* Details Modal Specifics */
+.detail-item {
+ margin-bottom: 0.85rem;
+}
+
+.detail-label {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+.detail-val {
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--text-primary);
+ margin-top: 2px;
+}
+
+/* Toast Notification */
+.toast-notification {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%) translateY(100px);
+ background: var(--bg-surface-elevated);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ padding: 0.75rem 1.5rem;
+ border-radius: var(--radius-full);
+ font-size: 0.85rem;
+ font-weight: 500;
+ box-shadow: var(--shadow-lg);
+ z-index: 2000;
+ opacity: 0;
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+ pointer-events: none;
+}
+
+.toast-notification.show {
+ transform: translateX(-50%) translateY(0);
+ opacity: 1;
+}
+
+/* Empty State */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ text-align: center;
+ padding: 2rem;
+}
+
+.empty-state h3 {
+ font-size: 1.3rem;
+ margin-bottom: 0.5rem;
+}
+
+.empty-state p {
+ color: var(--text-secondary);
+ max-width: 400px;
+ margin-bottom: 1.5rem;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .app-header {
+ padding: 0 1rem;
+ }
+
+ .brand-subtitle, .view-toggle-group span, .canvas-hint {
+ display: none;
+ }
+
+ .search-box input {
+ width: 140px;
+ }
+
+ .search-box input:focus {
+ width: 180px;
+ }
+
+ .btn span {
+ display: none;
+ }
+
+ .btn {
+ padding: 0.5rem;
+ }
}
\ No newline at end of file