diff --git a/script.js b/script.js index 49be7a6..4eba4de 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,4 @@ + // Firebase Configuration const firebaseConfig = { apiKey: "AIzaSyD-E58BdRwFCHDmsgRG9-PEWzMaQoheJ-0", @@ -29,6 +30,15 @@ const memberForm = document.getElementById('member-form'); // 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 +}; // --- Authentication --- @@ -70,132 +80,6 @@ darkModeToggle.addEventListener('click', () => { darkModeToggle.textContent = isDark ? '🌙' : '☀️'; }); -// Tabs -navBtns.forEach(btn => { - btn.addEventListener('click', () => { - // Remove active class from all - navBtns.forEach(b => b.classList.remove('active')); - tabContents.forEach(c => c.classList.remove('active')); - - // Add active to clicked - btn.classList.add('active'); - const tabId = btn.getAttribute('data-tab'); - document.getElementById(`${tabId}-tab`).classList.add('active'); - }); -}); - -// Modals -addMemberBtn.addEventListener('click', () => { - document.getElementById('modal-title').textContent = "Legg til person"; - memberForm.reset(); - document.getElementById('member-id').value = ''; - - // Clear children list - const childrenList = document.getElementById('children-list'); - if (childrenList) childrenList.innerHTML = '
  • Ingen registrerte barn
  • '; - - populateDropdowns(); // Populate relationships - memberModal.style.display = 'flex'; -}); - -closeModalBtns.forEach(btn => { - btn.addEventListener('click', () => { - btn.closest('.modal').style.display = 'none'; - }); -}); - -window.addEventListener('click', (e) => { - if (e.target === memberModal) { - memberModal.style.display = 'none'; - } -}); - -// --- Data Handling --- - -function loadFamilyData() { - console.log("Loading family data..."); - db.collection('familyMembers').onSnapshot(snapshot => { - familyData = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); - renderTree(); - renderHistory(); - initMap(); // Re-init map markers - }, error => { - console.error("Error loading data:", error); - }); -} - -function populateDropdowns(excludeId = null) { - const motherSelect = document.getElementById('motherId'); - const fatherSelect = document.getElementById('fatherId'); - const spouseSelect = document.getElementById('spouseId'); - - // Clear existing options - [motherSelect, fatherSelect, spouseSelect].forEach(select => { - if (select) select.innerHTML = ''; - }); - - familyData.forEach(member => { - if (member.id === excludeId) return; // Don't link to self - - const option = document.createElement('option'); - option.value = member.id; - option.textContent = `${member.firstName} ${member.lastName}`; - - // Add to appropriate dropdowns based on gender/logic - // Spouse can be anyone for now - if (spouseSelect) spouseSelect.appendChild(option.cloneNode(true)); - - if (member.gender === 'female') { - if (motherSelect) motherSelect.appendChild(option.cloneNode(true)); - } else if (member.gender === 'male') { - if (fatherSelect) fatherSelect.appendChild(option.cloneNode(true)); - } else { - // If gender undefined, maybe add to both or neither? - // For now, let's add to both to be safe if data is missing - - if (motherSelect) motherSelect.appendChild(option.cloneNode(true)); - if (fatherSelect) fatherSelect.appendChild(option.cloneNode(true)); - } - }); -} - -memberForm.addEventListener('submit', (e) => { - e.preventDefault(); - const id = document.getElementById('member-id').value; - - const memberData = { - 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), - lng: parseFloat(document.getElementById('lng')?.value), - notes: document.getElementById('notes')?.value, - motherId: document.getElementById('motherId').value, - fatherId: document.getElementById('fatherId').value, - spouseId: document.getElementById('spouseId').value - }; - - // Remove undefined/empty fields - Object.keys(memberData).forEach(key => { - if (memberData[key] === undefined || memberData[key] === '') { - delete memberData[key]; - } - }); - - if (id) { - db.collection('familyMembers').doc(id).update(memberData) - .then(() => memberModal.style.display = 'none') - .catch(error => console.error("Error updating member:", error)); - } else { - db.collection('familyMembers').add(memberData) - .then(() => memberModal.style.display = 'none') - .catch(error => console.error("Error adding member:", error)); - } -}); - // Geocoding document.getElementById('geocodeBtn').addEventListener('click', () => { const location = document.getElementById('location').value; @@ -215,77 +99,217 @@ document.getElementById('geocodeBtn').addEventListener('click', () => { }); }); -// --- Feature: Family Tree --- +// --- 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 = `
    + Kunne ikke hente data: ${error.message}
    + Sjekk konsollen for detaljer. +
    `; + } + }); +} + +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'); - container.innerHTML = ''; + const treeTab = document.getElementById('tree-tab'); - if (familyData.length === 0) return; - - // 1. Calculate Generations - const memberMap = new Map(familyData.map(m => [m.id, m])); - const generations = {}; - const processed = new Set(); - - function getGeneration(id, depth = 0) { - if (depth > 20) return 0; // Prevent infinite recursion - const member = memberMap.get(id); - if (!member) return 0; - - let parentGen = -1; - if (member.motherId) parentGen = Math.max(parentGen, getGeneration(member.motherId, depth + 1)); - if (member.fatherId) parentGen = Math.max(parentGen, getGeneration(member.fatherId, depth + 1)); - - return parentGen + 1; + // 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'; } - familyData.forEach(member => { - const gen = getGeneration(member.id); - if (!generations[gen]) generations[gen] = []; - generations[gen].push(member); - }); + if (!container) return; + container.innerHTML = ''; - // 2. Position Members - const cardWidth = 220; - const cardHeight = 100; // Approx - const gapX = 40; - const gapY = 150; - const startY = 50; + const svg = document.getElementById('relationship-lines'); + if (svg) svg.innerHTML = ''; - // Sort generations to ensure we process 0, 1, 2... - const genKeys = Object.keys(generations).sort((a, b) => a - b); + if (!familyData || familyData.length === 0) { + container.innerHTML = '
    Ingen familiemedlemmer funnet. Klikk på + for å legge til.
    '; + return; + } - // Store positions for line drawing - const positions = {}; // id -> {x, y, width, height} + try { + const memberMap = new Map(familyData.map(m => [m.id, { ...m }])); + const childrenMap = new Map(); + const partnerAdjacency = new Map(); - genKeys.forEach(genKey => { - const members = generations[genKey]; - // Sort members to try and keep spouses together (simple heuristic) - members.sort((a, b) => { - if (a.spouseId === b.id) return -1; - return 0; + const addRelation = (id1, id2, type) => { + if (!id1 || !id2) return; + if (!partnerAdjacency.has(id1)) partnerAdjacency.set(id1, []); + if (!partnerAdjacency.has(id2)) partnerAdjacency.set(id2, []); + + if (!partnerAdjacency.get(id1).find(r => r.id === id2)) { + partnerAdjacency.get(id1).push({ id: id2, type }); + } + 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); + } + }); + } }); - // Center the row relative to the container - // We'll use a fixed large width for the container to allow scrolling - // Let's assume center is 1000px - const rowWidth = members.length * (cardWidth + gapX) - gapX; - let startX = Math.max(50, 1000 - rowWidth / 2); + const renderedSet = new Set(); + const placedNodes = []; - members.forEach((member, index) => { - const x = startX + (index * (cardWidth + gapX)); - const y = startY + (genKey * gapY); + const CARD_WIDTH = 220; + const GAP_X = 50; + const UNIT_GAP = 100; + const GAP_Y = 250; - positions[member.id] = { x, y, width: cardWidth, height: cardHeight }; + 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 = `
    ${genderIcon}
    ${member.firstName} ${member.lastName}
    @@ -293,294 +317,403 @@ function renderTree() { ${new Date(member.birthDate).getFullYear()} - ${member.deathDate ? new Date(member.deathDate).getFullYear() : ''} `; - - card.addEventListener('click', () => openEditModal(member)); container.appendChild(card); - }); - }); - - // 3. Draw Lines - drawLines(positions); -} - -function drawLines(positions) { - const svg = document.getElementById('relationship-lines'); - svg.innerHTML = ''; - - // Set SVG size to match content - const maxX = Math.max(...Object.values(positions).map(p => p.x + p.width)) + 100; - const maxY = Math.max(...Object.values(positions).map(p => p.y + p.height)) + 100; - - svg.setAttribute('width', Math.max(2000, maxX)); // Min width 2000 for scrolling - svg.setAttribute('height', Math.max(1000, maxY)); - - familyData.forEach(member => { - const pos = positions[member.id]; - if (!pos) return; - - // Parent Connections - [member.motherId, member.fatherId].forEach(parentId => { - if (parentId && positions[parentId]) { - const parentPos = positions[parentId]; - - // Draw line from bottom of parent to top of child - const x1 = parentPos.x + parentPos.width / 2; - const parentBottom = parentPos.y + 120; // Approx bottom of card - - const x2 = pos.x + pos.width / 2; - const y2 = pos.y; - - // Bezier curve - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const d = `M ${x1} ${parentBottom} C ${x1} ${parentBottom + 50}, ${x2} ${y2 - 50}, ${x2} ${y2}`; - - path.setAttribute("d", d); - path.setAttribute("stroke", "#88a090"); // Moss color - path.setAttribute("stroke-width", "2"); - path.setAttribute("fill", "none"); - svg.appendChild(path); - } - }); - - // Spouse Connection - if (member.spouseId && positions[member.spouseId]) { - const spousePos = positions[member.spouseId]; - // Only draw once (e.g., if id < spouseId) - if (member.id < member.spouseId) { - const x1 = pos.x + pos.width; - const y1 = pos.y + 60; // Middle of card approx - const x2 = spousePos.x; - const y2 = spousePos.y + 60; - - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const d = `M ${x1} ${y1} L ${x2} ${y2}`; - - path.setAttribute("d", d); - path.setAttribute("stroke", "#1a472a"); // Forest color - path.setAttribute("stroke-width", "2"); - path.setAttribute("stroke-dasharray", "5,5"); // Dashed for spouse - path.setAttribute("fill", "none"); - svg.appendChild(path); - } - } - }); -} - -function openEditModal(member) { - document.getElementById('modal-title').textContent = "Rediger person"; - document.getElementById('member-id').value = member.id; - document.getElementById('firstName').value = member.firstName; - document.getElementById('lastName').value = member.lastName; - document.getElementById('birthDate').value = member.birthDate || ''; - document.getElementById('deathDate').value = member.deathDate || ''; - document.getElementById('location').value = member.location || ''; - document.getElementById('lat').value = member.lat || ''; - document.getElementById('lng').value = member.lng || ''; - document.getElementById('notes').value = member.notes || ''; - - // Set Gender - const genderInputs = document.getElementsByName('gender'); - for (const input of genderInputs) { - if (input.value === member.gender) { - input.checked = true; - } - } - - // Populate dropdowns first (excluding self) - populateDropdowns(member.id); - - // Set Relationships - document.getElementById('motherId').value = member.motherId || ''; - document.getElementById('fatherId').value = member.fatherId || ''; - document.getElementById('spouseId').value = member.spouseId || ''; - - // List Children - const childrenList = document.getElementById('children-list'); - if (childrenList) { - childrenList.innerHTML = ''; - const children = familyData.filter(m => m.motherId === member.id || m.fatherId === member.id); - - if (children.length === 0) { - childrenList.innerHTML = '
  • Ingen registrerte barn
  • '; - } else { - children.forEach(child => { - const li = document.createElement('li'); - li.textContent = `${child.firstName} ${child.lastName}`; - li.style.padding = '4px 0'; - li.style.borderBottom = '1px solid rgba(0,0,0,0.05)'; - childrenList.appendChild(li); + 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(); }); - } - } - // Add Child Button Handler - const addChildBtn = document.getElementById('addChildBtn'); - if (addChildBtn) { - addChildBtn.onclick = () => { - // Close current modal - memberModal.style.display = 'none'; - // Open new modal for child - setTimeout(() => { - document.getElementById('modal-title').textContent = "Legg til barn"; - memberForm.reset(); - document.getElementById('member-id').value = ''; - populateDropdowns(); + placedNodes.push({ ...member, x, y, width: CARD_WIDTH, height: 100 }); + renderedSet.add(member.id); + }; - // Pre-select parent based on gender - if (member.gender === 'female') { - document.getElementById('motherId').value = member.id; - } else if (member.gender === 'male') { - document.getElementById('fatherId').value = 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; } - // Pre-fill last name - document.getElementById('lastName').value = member.lastName; + // --- Single / Orphaned Children --- + const myChildren = childrenMap.get(id) || []; + const unrenderedChildren = myChildren.filter(kidId => !renderedSet.has(kidId)); - memberModal.style.display = 'flex'; - }, 100); + 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; }; - } - memberModal.style.display = 'flex'; + 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}
    `; + } } -// --- Feature: Map --- +function drawStaticLines(nodes, edges) { + const svg = document.getElementById('relationship-lines'); + if (!svg) return; -// Global function for Google Maps callback -window.initMap = function () { - const mapElement = document.getElementById('map'); - if (!mapElement) return; - - // Default to Høvåg/Røsstad area - const center = { lat: 58.16, lng: 8.25 }; - - const map = new google.maps.Map(mapElement, { - zoom: 10, - center: center, - styles: [ /* Optional: Add custom map styles for dark mode here */] + 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; }); - // Add markers for family members - familyData.forEach(member => { - if (member.lat && member.lng) { - const marker = new google.maps.Marker({ - position: { lat: member.lat, lng: member.lng }, - map: map, - title: `${member.firstName} ${member.lastName}` - }); + // 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)); - const infoWindow = new google.maps.InfoWindow({ - content: ` -
    - ${member.firstName} ${member.lastName}
    - ${member.location}
    - ${new Date(member.birthDate).getFullYear()} -
    - ` - }); + let html = ''; + const nodeLookup = new Map(nodes.map(n => [n.id, n])); - marker.addListener('click', () => { - infoWindow.open(map, marker); - }); + // 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 += ``; } }); -} -// --- Feature: History --- + // 2. Draw Parent-Child Lines (Bracket Style) + const groups = new Map(); -function renderHistory() { - const list = document.getElementById('history-list'); - list.innerHTML = ''; - - // Sort by birthdate (Oldest first) - const sortedMembers = [...familyData].sort((a, b) => { - return new Date(a.birthDate) - new Date(b.birthDate); + 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); + } + } }); - sortedMembers.forEach(member => { - const item = document.createElement('li'); - item.className = 'history-item'; - const year = new Date(member.birthDate).getFullYear(); - item.innerHTML = ` - ${year} - - ${member.firstName} ${member.lastName} ble født. - `; - list.appendChild(item); + 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; + + 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 += ``; + }); + + if (children.length > 1) { + html += ``; + } else { + const childX = children[0].x + children[0].width / 2; + if (Math.abs(startX - childX) > 1) { + html += ``; + } + } }); + + svg.innerHTML = html; } // --- Feature: Family Tree Navigation (Pan/Zoom) --- -let treeState = { - scale: 1, - panning: false, - pointX: 0, - pointY: 0, - startX: 0, - startY: 0 -}; - function setupPanZoom() { const treeTab = document.getElementById('tree-tab'); const treeContainer = document.getElementById('tree-container'); + if (!treeTab || !treeContainer) { + console.error("SetupPanZoom: Elements not found"); + return; + } + + console.log("SetupPanZoom: Initializing..."); + + // 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'; + // Zoom (Wheel) treeTab.addEventListener('wheel', (e) => { e.preventDefault(); - const xs = (e.clientX - treeState.pointX) / treeState.scale; - const ys = (e.clientY - treeState.pointY) / treeState.scale; + // Safety against NaN + if (isNaN(treeState.scale)) treeState.scale = 1; + if (isNaN(treeState.pointX)) treeState.pointX = 0; + if (isNaN(treeState.pointY)) treeState.pointY = 0; - const delta = -e.deltaY; - (delta > 0) ? (treeState.scale *= 1.1) : (treeState.scale /= 1.1); - - // Limit zoom - 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; - - setTransform(); + 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; + } + updateTransform(); }); // Pan (Mouse) treeTab.addEventListener('mousedown', (e) => { - if (e.target.closest('.family-card') || e.target.closest('.fab')) return; // Don't drag if clicking card/button + // 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'; }); - treeTab.addEventListener('mousemove', (e) => { + // 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; - setTransform(); + updateTransform(); }); - treeTab.addEventListener('mouseup', () => { - treeState.panning = false; - }); + const stopPan = () => { + if (treeState.panning) { + console.log("MouseUp: Stopping pan"); + treeState.panning = false; + treeTab.style.cursor = 'grab'; + } + }; - treeTab.addEventListener('mouseleave', () => { - treeState.panning = false; - }); + window.addEventListener('mouseup', stopPan); + // Also stop on mouseleave if desired, but window mouseup usually catches it + treeTab.addEventListener('mouseleave', stopPan); - function setTransform() { - treeContainer.style.transform = `translate(${treeState.pointX}px, ${treeState.pointY}px) scale(${treeState.scale})`; - } - - // Initial center (approx) - treeState.pointX = (window.innerWidth - 1000) / 2; // Assuming 1000px content width - treeState.pointY = 50; - setTransform(); + // Global click logger REMOVED } // 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')); + + btn.classList.add('active'); + const tabId = btn.getAttribute('data-tab'); + document.getElementById(tabId).classList.add('active'); + + 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."); + } + } else if (tabId === 'history-tab') { + renderHistory(); + } + }); +}); diff --git a/style.css b/style.css index 27ad68b..cd82bcb 100644 --- a/style.css +++ b/style.css @@ -134,9 +134,18 @@ body.dark-mode .nav-brand { #tree-container { width: 100%; height: 100%; - overflow: auto; - position: relative; - background: radial-gradient(circle at center, rgba(26, 71, 42, 0.1) 0%, transparent 70%); + 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 */ +} + +#tree-container:active { + cursor: grabbing; } #relationship-lines { @@ -153,6 +162,8 @@ body.dark-mode .nav-brand { left: 0; width: 100%; height: 100%; + pointer-events: none; + /* Let clicks pass to #tree-tab */ } .family-card { @@ -171,6 +182,8 @@ body.dark-mode .nav-brand { cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; color: inherit; + pointer-events: auto; + /* Re-enable clicking for cards */ } .family-card:hover { @@ -378,23 +391,17 @@ input[type="radio"] { margin: 0; } -/* Pan and Zoom Styles */ #tree-tab { - overflow: hidden; /* Hide scrollbars, we use custom pan */ + overflow: hidden; + /* Infinite canvas, no native scroll */ position: relative; cursor: grab; + width: 100%; + height: 100%; + user-select: none; + /* Prevent text selection during drag */ } #tree-tab:active { cursor: grabbing; -} - -#tree-container { - transform-origin: 0 0; /* Zoom from top-left */ - position: absolute; - top: 0; - left: 0; - /* Ensure it has size */ - min-width: 100%; - min-height: 100%; } \ No newline at end of file