// 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"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
const auth = firebase.auth();
const db = firebase.firestore();
// 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');
// 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 ---
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';
}
});
loginForm.addEventListener('submit', (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
auth.signInWithEmailAndPassword(email, password)
.catch(error => {
loginError.textContent = "Feil e-post eller passord.";
console.error(error);
});
});
logoutBtn.addEventListener('click', () => {
auth.signOut();
});
// --- UI Logic ---
// Dark Mode
darkModeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
const isDark = document.body.classList.contains('dark-mode');
darkModeToggle.textContent = isDark ? '🌙' : '☀️';
});
// Geocoding
document.getElementById('geocodeBtn').addEventListener('click', () => {
const location = document.getElementById('location').value;
if (!location) return;
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);
} else {
alert('Kunne ikke finne koordinater for dette stedet: ' + status);
}
});
});
// --- 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');
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;
}
try {
const memberMap = new Map(familyData.map(m => [m.id, { ...m }]));
const childrenMap = new Map();
const partnerAdjacency = new Map();
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);
}
});
}
});
const renderedSet = new Set();
const placedNodes = [];
const CARD_WIDTH = 220;
const GAP_X = 50;
const UNIT_GAP = 100;
const GAP_Y = 250;
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}
${new Date(member.birthDate).getFullYear()} - ${member.deathDate ? new Date(member.deathDate).getFullYear() : ''}
`;
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;
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) ---
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();
// 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;
}
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';
}
};
window.addEventListener('mouseup', stopPan);
// Also stop on mouseleave if desired, but window mouseup usually catches it
treeTab.addEventListener('mouseleave', stopPan);
// 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();
}
});
});