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