Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Infinite Family Tree Builder</title> | |
| <style> | |
| :root{--bg:#f5f7fb;--panel:#ffffff;--accent:#2b6cff;--muted:#6b7280} | |
| html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;background:var(--bg);color:#111} | |
| .app { | |
| display:flex; gap:18px; padding:18px; box-sizing:border-box; | |
| } | |
| .panel { | |
| width:320px; max-height:calc(100vh - 36px); overflow:auto; | |
| background:var(--panel); border-radius:12px; padding:14px; box-shadow:0 8px 24px rgba(20,30,60,0.08) | |
| } | |
| header h1{margin:0;font-size:18px} | |
| .controls {margin-top:12px; display:grid; gap:8px} | |
| .controls button, .controls input, .controls select { | |
| padding:8px 10px; border-radius:8px; border:1px solid #e6e9ef; background:white; font-size:14px | |
| } | |
| .info {margin-top:12px; color:var(--muted); font-size:13px} | |
| #canvasWrap {flex:1; position:relative; min-height:600px; background:linear-gradient(180deg,#ffffff, #f0f4ff); border-radius:12px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.6)} | |
| canvas {width:100%; height:100%; display:block; border-radius:12px} | |
| .legend{font-size:13px;color:var(--muted); margin-top:10px} | |
| .small{font-size:13px} | |
| .status {margin-top:10px; font-weight:600} | |
| label.small{display:flex;gap:8px;align-items:center} | |
| footer {margin-top:12px;color:var(--muted);font-size:13px} | |
| /* node tooltip/edit */ | |
| #editor { | |
| position:absolute; left:10px; bottom:10px; background:rgba(255,255,255,0.96); border-radius:10px; padding:10px; box-shadow:0 8px 20px rgba(10,20,40,0.12); | |
| min-width:260px; display:none; z-index:40; | |
| } | |
| #editor input, #editor textarea {width:100%; box-sizing:border-box; padding:6px 8px; border-radius:8px; border:1px solid #e6e9ef; margin-top:6px} | |
| .node-small {font-size:12px; color:var(--muted)} | |
| .btn-quiet{background:#f1f5ff;border:1px solid #e6ecff;color:var(--accent)} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <div class="panel"> | |
| <header> | |
| <h1>Infinite Family Tree</h1> | |
| <div class="small">Create, edit & test very large family trees — pan & zoom to explore.</div> | |
| </header> | |
| <div class="controls"> | |
| <button id="newRootBtn">+ Add Root Person</button> | |
| <div style="display:flex;gap:8px"> | |
| <input id="searchInput" placeholder="Search name..." /> | |
| <button id="searchBtn">Search</button> | |
| </div> | |
| <div style="display:flex;gap:8px"> | |
| <button id="addChildBtn" class="btn-quiet">Add Child</button> | |
| <button id="delNodeBtn" class="btn-quiet">Delete</button> | |
| </div> | |
| <button id="collapseBtn">Collapse / Expand Selected</button> | |
| <div style="display:flex;gap:8px"> | |
| <button id="animatePathBtn">▶ Animate Path (root → selected)</button> | |
| <button id="centerBtn">Center</button> | |
| </div> | |
| <div style="display:flex;gap:8px"> | |
| <button id="saveBtn">💾 Save JSON</button> | |
| <button id="loadBtn">📂 Load JSON</button> | |
| </div> | |
| <div style="display:flex;gap:8px"> | |
| <button id="exportBtn">🖼️ Export PNG</button> | |
| <button id="clearBtn" style="background:#fff7f7;border:1px solid #ffd6d6">Clear All</button> | |
| </div> | |
| </div> | |
| <div class="info"> | |
| <div>Controls:</div> | |
| <div class="legend">• Drag canvas to pan (or hold Space + drag)<br>• Scroll to zoom<br>• Click node to select, double-click to edit<br>• Del to delete selected</div> | |
| </div> | |
| <div class="status" id="status">Status: Ready</div> | |
| <footer>Made for exploring infinite family trees — lightweight, no external libraries.</footer> | |
| </div> | |
| <div id="canvasWrap"> | |
| <canvas id="treeCanvas" width="1600" height="1200"></canvas> | |
| <!-- inline editor --> | |
| <div id="editor"> | |
| <div style="font-weight:700" id="editorTitle">Edit Person</div> | |
| <label class="small">Name<input id="nameField" /></label> | |
| <label class="small">Year <input id="yearField" /></label> | |
| <label class="small">Notes <textarea id="notesField" rows="3"></textarea></label> | |
| <div style="display:flex;gap:8px;margin-top:8px"> | |
| <button id="saveNodeBtn">Save</button> | |
| <button id="cancelEditBtn" class="btn-quiet">Cancel</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| /* | |
| Infinite Family Tree Builder | |
| - Node structure: { id, name, year, notes, children:[], collapsed:false } | |
| - Layout: top-down, node width constant; subtree widths sum of child subtree widths. | |
| - Canvas transform supports pan/zoom. Click/double-click/drag supported. | |
| */ | |
| (() => { | |
| // --- Data model --- | |
| let nextId = 1; | |
| let root = null; | |
| let nodesById = new Map(); | |
| function createPerson(name="New Person", year="", notes="") { | |
| const id = String(nextId++); | |
| const node = { id, name, year, notes, children: [], collapsed:false, parent: null }; | |
| nodesById.set(id, node); | |
| return node; | |
| } | |
| function addChild(parentId, childNode) { | |
| const parent = nodesById.get(parentId); | |
| if (!parent) return; | |
| parent.children.push(childNode); | |
| childNode.parent = parentId; | |
| } | |
| function removeNode(id) { | |
| const node = nodesById.get(id); | |
| if (!node) return; | |
| // remove from parent's children | |
| if (node.parent) { | |
| const p = nodesById.get(node.parent); | |
| p.children = p.children.filter(c => c.id !== id); | |
| } else { | |
| // root removed | |
| root = null; | |
| } | |
| // recursively delete subtree | |
| (function del(n) { | |
| n.children.forEach(c => del(nodesById.get(c.id))); | |
| nodesById.delete(n.id); | |
| })(node); | |
| } | |
| // --- View / Canvas --- | |
| const canvas = document.getElementById("treeCanvas"); | |
| const ctx = canvas.getContext("2d", { alpha:true }); | |
| let viewW = canvas.width = canvas.clientWidth * devicePixelRatio; | |
| let viewH = canvas.height = canvas.clientHeight * devicePixelRatio; | |
| canvas.style.width = canvas.clientWidth + "px"; | |
| canvas.style.height = canvas.clientHeight + "px"; | |
| ctx.scale(devicePixelRatio, devicePixelRatio); | |
| // pan & zoom | |
| let panX = 0, panY = 0, zoom = 1; | |
| let draggingCanvas = false, dragLast = null; | |
| let spacePan = false; | |
| // node rendering params | |
| const NODE_W = 120, NODE_H = 50, H_SPACING = 20, V_SPACING = 60; | |
| // layout cache | |
| const layoutPositions = new Map(); // id -> {x,y} | |
| // selected | |
| let selectedId = null; | |
| // editor elements | |
| const editor = document.getElementById("editor"); | |
| const nameField = document.getElementById("nameField"); | |
| const yearField = document.getElementById("yearField"); | |
| const notesField = document.getElementById("notesField"); | |
| // status | |
| const statusBox = document.getElementById("status"); | |
| // responsive canvas resize | |
| function resizeCanvas() { | |
| // update css size -> keep pixel ratio consistent | |
| const wrap = document.getElementById("canvasWrap"); | |
| const rect = wrap.getBoundingClientRect(); | |
| canvas.width = Math.floor(rect.width * devicePixelRatio); | |
| canvas.height = Math.floor(rect.height * devicePixelRatio); | |
| canvas.style.width = rect.width + "px"; | |
| canvas.style.height = rect.height + "px"; | |
| ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0); | |
| draw(); | |
| } | |
| window.addEventListener("resize", resizeCanvas); | |
| // --- Layout algorithm (simple recursive) --- | |
| function computeLayout() { | |
| layoutPositions.clear(); | |
| if (!root) return; | |
| // compute subtree width in "units" based on leaf node widths | |
| function computeWidth(node) { | |
| if (!node.children.length || node.collapsed) { | |
| node._subWidth = NODE_W; | |
| return node._subWidth; | |
| } | |
| let sum = 0; | |
| node.children.forEach(c => sum += computeWidth(nodesById.get(c.id)) + H_SPACING); | |
| sum = Math.max(sum - H_SPACING, NODE_W); | |
| node._subWidth = sum; | |
| return node._subWidth; | |
| } | |
| function place(node, x, y) { | |
| // x denotes left boundary for this subtree | |
| const cx = x + (node._subWidth - NODE_W)/2; | |
| layoutPositions.set(node.id, { x: cx, y }); | |
| if (!node.children.length || node.collapsed) return; | |
| let curX = x; | |
| node.children.forEach(c => { | |
| const child = nodesById.get(c.id); | |
| place(child, curX, y + NODE_H + V_SPACING); | |
| curX += child._subWidth + H_SPACING; | |
| }); | |
| } | |
| computeWidth(root); | |
| // center root in canvas logical coords | |
| const startX = (canvas.clientWidth - root._subWidth) / 2; | |
| place(root, startX, 20); | |
| } | |
| // convert world -> screen coords (logical to canvas coords considering pan/zoom) | |
| function worldToScreen(x,y) { | |
| return { sx: (x + panX) * zoom, sy: (y + panY) * zoom }; | |
| } | |
| function screenToWorld(sx,sy) { | |
| return { x: sx/zoom - panX, y: sy/zoom - panY }; | |
| } | |
| // draw function | |
| function draw() { | |
| // clear | |
| ctx.save(); | |
| // Clear with background | |
| ctx.clearRect(0,0,canvas.width, canvas.height); | |
| // transform | |
| ctx.translate(0,0); | |
| ctx.scale(zoom, zoom); | |
| ctx.translate(panX, panY); | |
| // recompute layout | |
| computeLayout(); | |
| // draw edges (parent->child) | |
| ctx.lineWidth = 2/zoom; | |
| ctx.strokeStyle = "#cbd5e1"; | |
| ctx.beginPath(); | |
| if (root) { | |
| nodesById.forEach(node => { | |
| const pos = layoutPositions.get(node.id); | |
| if (!pos) return; | |
| node.children.forEach(c => { | |
| const childPos = layoutPositions.get(c.id); | |
| if (!childPos) return; | |
| // draw a smooth cubic curve | |
| const x1 = pos.x + NODE_W/2, y1 = pos.y + NODE_H; | |
| const x2 = childPos.x + NODE_W/2, y2 = childPos.y; | |
| const mx = (x1 + x2)/2; | |
| ctx.moveTo(x1, y1); | |
| ctx.bezierCurveTo(mx, y1 + 10, mx, y2 - 10, x2, y2); | |
| }); | |
| }); | |
| ctx.stroke(); | |
| } | |
| // draw nodes | |
| nodesById.forEach(node => { | |
| const pos = layoutPositions.get(node.id); | |
| if (!pos) return; | |
| const x = pos.x, y = pos.y; | |
| const isSelected = node.id === selectedId; | |
| // node box | |
| ctx.beginPath(); | |
| roundRect(ctx, x, y, NODE_W, NODE_H, 8); | |
| if (isSelected) { | |
| ctx.fillStyle = "#eef2ff"; | |
| ctx.strokeStyle = "#2b6cff"; | |
| ctx.lineWidth = 2/zoom; | |
| } else { | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.strokeStyle = "#dbe6ff"; | |
| ctx.lineWidth = 1/zoom; | |
| } | |
| ctx.fill(); | |
| ctx.stroke(); | |
| // collapsed indicator | |
| if (node.children.length) { | |
| ctx.beginPath(); | |
| ctx.fillStyle = node.collapsed ? "#ffd7d7" : "#f1f5f9"; | |
| ctx.arc(x + NODE_W - 12, y + 12, 8, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#e6eaf6"; | |
| ctx.stroke(); | |
| ctx.fillStyle = "#444"; | |
| ctx.font = (12/zoom) + "px sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| ctx.fillText(node.collapsed ? "+" : "−", x + NODE_W - 12, y + 12); | |
| } | |
| // text: name & year | |
| ctx.fillStyle = "#0f172a"; | |
| ctx.font = (14/zoom) + "px system-ui, sans-serif"; | |
| ctx.textAlign = "left"; | |
| ctx.textBaseline = "top"; | |
| wrapText(ctx, node.name || "(no name)", x + 8, y + 8, NODE_W - 16, 16/zoom); | |
| ctx.fillStyle = "#64748b"; | |
| ctx.font = (12/zoom) + "px sans-serif"; | |
| ctx.fillText(node.year || "", x + 8, y + NODE_H - (16/zoom)); | |
| }); | |
| ctx.restore(); | |
| } | |
| // helper: rounded rect | |
| function roundRect(ctx, x, y, w, h, r) { | |
| ctx.moveTo(x + r, y); | |
| ctx.arcTo(x + w, y, x + w, y + h, r); | |
| ctx.arcTo(x + w, y + h, x, y + h, r); | |
| ctx.arcTo(x, y + h, x, y, r); | |
| ctx.arcTo(x, y, x + w, y, r); | |
| } | |
| // helper: wrap text | |
| function wrapText(ctx, text, x, y, maxWidth, lineHeight) { | |
| const words = text.split(/\s+/); | |
| let line = "", yoff = y; | |
| for (let n = 0; n < words.length; n++) { | |
| const testLine = line ? (line + " " + words[n]) : words[n]; | |
| const metrics = ctx.measureText(testLine); | |
| if (metrics.width > maxWidth && line) { | |
| ctx.fillText(line, x, yoff); | |
| line = words[n]; | |
| yoff += lineHeight; | |
| } else { | |
| line = testLine; | |
| } | |
| } | |
| if (line) ctx.fillText(line, x, yoff); | |
| } | |
| // hit testing: find node under screen coords | |
| function findNodeAtScreen(sx, sy) { | |
| const w = screenToWorld(sx, sy); | |
| // check nodes in reverse insertion order to prefer later nodes | |
| let found = null; | |
| nodesById.forEach(node => { | |
| const pos = layoutPositions.get(node.id); | |
| if (!pos) return; | |
| const x = pos.x, y = pos.y; | |
| if (w.x >= x && w.x <= x + NODE_W && w.y >= y && w.y <= y + NODE_H) { | |
| found = node; | |
| } | |
| }); | |
| return found; | |
| } | |
| // --- UI wiring --- | |
| const newRootBtn = document.getElementById("newRootBtn"); | |
| const addChildBtn = document.getElementById("addChildBtn"); | |
| const delNodeBtn = document.getElementById("delNodeBtn"); | |
| const collapseBtn = document.getElementById("collapseBtn"); | |
| const animatePathBtn = document.getElementById("animatePathBtn"); | |
| const centerBtn = document.getElementById("centerBtn"); | |
| const saveBtn = document.getElementById("saveBtn"); | |
| const loadBtn = document.getElementById("loadBtn"); | |
| const exportBtn = document.getElementById("exportBtn"); | |
| const clearBtn = document.getElementById("clearBtn"); | |
| const searchBtn = document.getElementById("searchBtn"); | |
| const searchInput = document.getElementById("searchInput"); | |
| newRootBtn.addEventListener("click", () => { | |
| const person = createPerson("Ancestor " + (nextId-1), "", ""); | |
| root = person; | |
| status("Added root: " + person.name); | |
| draw(); | |
| }); | |
| addChildBtn.addEventListener("click", () => { | |
| if (!selectedId) { status("Select a person first."); return; } | |
| const child = createPerson("Child " + (nextId-1), "", ""); | |
| addChild(selectedId, child); | |
| status("Added child to " + nodesById.get(selectedId).name); | |
| draw(); | |
| }); | |
| delNodeBtn.addEventListener("click", () => { | |
| if (!selectedId) { status("Select a node to delete."); return; } | |
| removeNode(selectedId); | |
| selectedId = null; | |
| status("Node deleted."); | |
| draw(); | |
| }); | |
| collapseBtn.addEventListener("click", () => { | |
| if (!selectedId) { status("Select a node."); return; } | |
| const node = nodesById.get(selectedId); | |
| node.collapsed = !node.collapsed; | |
| status(node.collapsed ? "Collapsed subtree." : "Expanded subtree."); | |
| draw(); | |
| }); | |
| centerBtn.addEventListener("click", () => { | |
| centerOnRoot(); | |
| }); | |
| saveBtn.addEventListener("click", () => { | |
| if (!root) { status("Nothing to save."); return; } | |
| const json = JSON.stringify(exportTree(root), null, 2); | |
| downloadText(json, "family-tree.json"); | |
| status("Saved JSON."); | |
| }); | |
| loadBtn.addEventListener("click", () => { | |
| const input = document.createElement("input"); | |
| input.type = "file"; | |
| input.accept = "application/json"; | |
| input.onchange = e => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = evt => { | |
| try { | |
| const obj = JSON.parse(evt.target.result); | |
| importTree(obj); | |
| status("Loaded JSON."); | |
| draw(); | |
| } catch (err) { | |
| status("Failed to load JSON."); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| input.click(); | |
| }); | |
| exportBtn.addEventListener("click", () => { | |
| exportPNG(); | |
| }); | |
| clearBtn.addEventListener("click", () => { | |
| if (!confirm("Clear the entire tree?")) return; | |
| root = null; nodesById.clear(); nextId = 1; selectedId = null; | |
| status("Tree cleared."); | |
| draw(); | |
| }); | |
| searchBtn.addEventListener("click", () => { | |
| const q = (searchInput.value || "").trim().toLowerCase(); | |
| if (!q) return status("Type a name to search."); | |
| let found = null; | |
| nodesById.forEach(n => { | |
| if (!found && n.name.toLowerCase().includes(q)) found = n; | |
| }); | |
| if (found) { | |
| selectedId = found.id; | |
| centerOnNode(found.id); | |
| status("Found: " + found.name); | |
| draw(); | |
| } else { | |
| status("Not found."); | |
| } | |
| }); | |
| // animate path from root to selected (if selected is descendant) | |
| animatePathBtn.addEventListener("click", () => { | |
| if (!root) return status("No tree."); | |
| if (!selectedId) return status("Select a node to animate path to."); | |
| const path = findPath(root.id, selectedId); | |
| if (!path) return status("Selected is not a descendant of root."); | |
| animatePath(path); | |
| }); | |
| // find path root->target as array of ids | |
| function findPath(rootId, targetId) { | |
| const found = []; | |
| function dfs(node) { | |
| if (!node) return false; | |
| found.push(node.id); | |
| if (node.id === targetId) return true; | |
| for (const c of node.children) { | |
| if (dfs(nodesById.get(c.id))) return true; | |
| } | |
| found.pop(); | |
| return false; | |
| } | |
| return dfs(nodesById.get(rootId)) ? found.slice() : null; | |
| } | |
| // --- export/import helpers --- | |
| function exportTree(node) { | |
| return { | |
| id: node.id, | |
| name: node.name, year: node.year, notes: node.notes, collapsed: !!node.collapsed, | |
| children: node.children.map(c => exportTree(nodesById.get(c.id))) | |
| }; | |
| } | |
| function importTree(obj) { | |
| nodesById.clear(); | |
| nextId = 1; | |
| function rec(o, parentId=null) { | |
| const n = createPerson(o.name || "Person", o.year || "", o.notes || ""); | |
| n.id = o.id || n.id; | |
| // ensure nextId bigger | |
| nextId = Math.max(nextId, Number(n.id) + 1); | |
| n.collapsed = !!o.collapsed; | |
| n.children = []; | |
| n.parent = parentId; | |
| nodesById.set(n.id, n); | |
| (o.children || []).forEach(ch => { | |
| const child = rec(ch, n.id); | |
| n.children.push({ id: child.id }); | |
| }); | |
| return n; | |
| } | |
| root = rec(obj, null); | |
| } | |
| function downloadText(text, filename) { | |
| const a = document.createElement("a"); | |
| const blob = new Blob([text], { type: "application/json" }); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| } | |
| // --- canvas interactions --- | |
| canvas.addEventListener("mousedown", e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const sx = (e.clientX - rect.left); | |
| const sy = (e.clientY - rect.top); | |
| const hit = findNodeAtScreen(sx, sy); | |
| if (hit) { | |
| // select | |
| selectedId = hit.id; | |
| draw(); | |
| // double-click handled separately for editing | |
| draggingCanvas = false; | |
| } else { | |
| // start pan | |
| draggingCanvas = true; | |
| dragLast = { x: e.clientX, y: e.clientY }; | |
| } | |
| }); | |
| canvas.addEventListener("mousemove", e => { | |
| if (draggingCanvas && dragLast) { | |
| const dx = (e.clientX - dragLast.x) / zoom; | |
| const dy = (e.clientY - dragLast.y) / zoom; | |
| panX += dx; | |
| panY += dy; | |
| dragLast = { x: e.clientX, y: e.clientY }; | |
| draw(); | |
| } | |
| }); | |
| canvas.addEventListener("mouseup", e => { | |
| draggingCanvas = false; dragLast = null; | |
| }); | |
| canvas.addEventListener("mouseleave", () => { draggingCanvas = false; dragLast = null; }); | |
| // zoom with wheel (centered on cursor) | |
| canvas.addEventListener("wheel", e => { | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| const sx = e.clientX - rect.left, sy = e.clientY - rect.top; | |
| const before = screenToWorld(sx, sy); | |
| const delta = -e.deltaY * 0.001; | |
| const newZoom = Math.max(0.2, Math.min(3, zoom * (1 + delta))); | |
| zoom = newZoom; | |
| const after = screenToWorld(sx, sy); | |
| panX += (before.x - after.x); | |
| panY += (before.y - after.y); | |
| draw(); | |
| }, { passive:false }); | |
| // double click -> edit node inline | |
| canvas.addEventListener("dblclick", e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const sx = (e.clientX - rect.left); | |
| const sy = (e.clientY - rect.top); | |
| const hit = findNodeAtScreen(sx, sy); | |
| if (!hit) return; | |
| // create an input field overlayed on canvas | |
| const input = document.createElement("input"); | |
| input.type = "text"; | |
| input.value = hit.name; | |
| input.style.position = "absolute"; | |
| input.style.left = (e.clientX - 50) + "px"; | |
| input.style.top = (e.clientY - 15) + "px"; | |
| input.style.width = "120px"; | |
| input.style.padding = "4px 6px"; | |
| input.style.fontSize = "14px"; | |
| input.style.border = "1px solid #aaa"; | |
| input.style.borderRadius = "6px"; | |
| input.style.zIndex = 1000; | |
| document.body.appendChild(input); | |
| input.focus(); | |
| input.select(); | |
| function finish(save) { | |
| if (save) hit.name = input.value.trim() || "(no name)"; | |
| document.body.removeChild(input); | |
| draw(); | |
| } | |
| input.addEventListener("blur", () => finish(true)); | |
| input.addEventListener("keydown", ev => { | |
| if (ev.key === "Enter") finish(true); | |
| if (ev.key === "Escape") finish(false); | |
| }); | |
| }); | |
| // keyboard shortcuts | |
| window.addEventListener("keydown", e => { | |
| if (e.code === "Space") { spacePan = true; canvas.style.cursor = "grab"; e.preventDefault(); } | |
| if (e.key === "Delete" || e.key === "Backspace") { | |
| if (selectedId) { | |
| if (confirm("Delete selected person and their subtree?")) { | |
| removeNode(selectedId); | |
| selectedId = null; | |
| draw(); | |
| } | |
| } | |
| } | |
| }); | |
| window.addEventListener("keyup", e => { | |
| if (e.code === "Space") { spacePan = false; canvas.style.cursor = "default"; } | |
| }); | |
| // open inline editor | |
| function openEditor(node) { | |
| const wrap = document.getElementById("canvasWrap"); | |
| const rect = wrap.getBoundingClientRect(); | |
| const pos = layoutPositions.get(node.id); | |
| const screen = worldToScreen(pos.x, pos.y); | |
| // position editor near bottom-left | |
| editor.style.left = Math.min(rect.width - 280, (screen.sx / zoom) + 10) + "px"; | |
| editor.style.display = "block"; | |
| document.getElementById("editorTitle").textContent = "Edit: " + (node.name || ""); | |
| nameField.value = node.name || ""; | |
| yearField.value = node.year || ""; | |
| notesField.value = node.notes || ""; | |
| editor.dataset.editId = node.id; | |
| } | |
| document.getElementById("saveNodeBtn").addEventListener("click", () => { | |
| const id = editor.dataset.editId; | |
| const node = nodesById.get(id); | |
| if (!node) return; | |
| node.name = nameField.value.trim() || "(no name)"; | |
| node.year = yearField.value.trim(); | |
| node.notes = notesField.value.trim(); | |
| editor.style.display = "none"; | |
| draw(); | |
| }); | |
| document.getElementById("cancelEditBtn").addEventListener("click", () => { | |
| editor.style.display = "none"; | |
| }); | |
| // center on root | |
| function centerOnRoot() { | |
| if (!root) return; | |
| computeLayout(); | |
| const pos = layoutPositions.get(root.id); | |
| if (!pos) return; | |
| // center root in view | |
| const vw = canvas.clientWidth / 2, vh = canvas.clientHeight / 2; | |
| panX = vw/zoom - pos.x - NODE_W/2; | |
| panY = vh/zoom - pos.y - NODE_H/2; | |
| draw(); | |
| } | |
| // center on node | |
| function centerOnNode(id) { | |
| const pos = layoutPositions.get(id); | |
| if (!pos) return; | |
| const vw = canvas.clientWidth / 2, vh = canvas.clientHeight / 2; | |
| panX = vw/zoom - pos.x - NODE_W/2; | |
| panY = vh/zoom - pos.y - NODE_H/2; | |
| draw(); | |
| } | |
| // animate path: highlight nodes sequentially and pan to keep them visible | |
| function animatePath(pathIds) { | |
| let i = 0; | |
| const interval = 600; // ms per node | |
| function step() { | |
| if (i >= pathIds.length) { | |
| status("Animation finished."); | |
| return; | |
| } | |
| selectedId = pathIds[i]; | |
| centerOnNode(selectedId); | |
| draw(); | |
| i++; | |
| setTimeout(step, interval); | |
| } | |
| status("Animating path..."); | |
| step(); | |
| } | |
| // export PNG (renders at current zoom/pan) | |
| function exportPNG() { | |
| // create an offscreen canvas sized to current view in device pixels | |
| const rect = canvas.getBoundingClientRect(); | |
| const w = Math.floor(rect.width * devicePixelRatio); | |
| const h = Math.floor(rect.height * devicePixelRatio); | |
| const off = document.createElement("canvas"); | |
| off.width = w; off.height = h; | |
| const octx = off.getContext("2d"); | |
| // scale to device pixels | |
| octx.scale(devicePixelRatio, devicePixelRatio); | |
| // draw with same transform | |
| octx.save(); | |
| octx.scale(zoom, zoom); | |
| octx.translate(panX, panY); | |
| // draw background | |
| octx.fillStyle = "#fff"; | |
| octx.fillRect(0,0,rect.width,rect.height); | |
| // draw edges | |
| octx.lineWidth = 2/zoom; | |
| octx.strokeStyle = "#cbd5e1"; | |
| octx.beginPath(); | |
| nodesById.forEach(node => { | |
| const pos = layoutPositions.get(node.id); if (!pos) return; | |
| node.children.forEach(c => { | |
| const cp = layoutPositions.get(c.id); if (!cp) return; | |
| const x1 = pos.x + NODE_W/2, y1 = pos.y + NODE_H; | |
| const x2 = cp.x + NODE_W/2, y2 = cp.y; | |
| const mx = (x1 + x2)/2; | |
| octx.moveTo(x1, y1); | |
| octx.bezierCurveTo(mx, y1 + 10, mx, y2 - 10, x2, y2); | |
| }); | |
| }); | |
| octx.stroke(); | |
| // draw nodes (basic) | |
| nodesById.forEach(node => { | |
| const pos = layoutPositions.get(node.id); if (!pos) return; | |
| octx.beginPath(); roundRect(octx, pos.x, pos.y, NODE_W, NODE_H, 8); | |
| octx.fillStyle = "#fff"; octx.fill(); octx.strokeStyle = "#dbe6ff"; octx.stroke(); | |
| octx.fillStyle = "#0f172a"; | |
| octx.font = "14px sans-serif"; octx.textAlign = "left"; octx.textBaseline = "top"; | |
| octx.fillText(node.name||"(no name)", pos.x+8, pos.y+8); | |
| }); | |
| octx.restore(); | |
| // to dataURL | |
| const url = off.toDataURL("image/png"); | |
| const a = document.createElement("a"); | |
| a.href = url; a.download = "family-tree.png"; a.click(); | |
| URL.revokeObjectURL(a.href); | |
| status("PNG exported."); | |
| } | |
| // status helper | |
| function status(msg) { | |
| statusBox.textContent = "Status: " + msg; | |
| } | |
| // utility: animate loop to keep redraw if needed | |
| function tick() { | |
| requestAnimationFrame(tick); | |
| // nothing else, redraws are manual | |
| } | |
| tick(); | |
| // initialize with a sample root to help start | |
| (function initSample() { | |
| const r = createPerson("Alex Johnson", "1945", "Root ancestor"); | |
| root = r; | |
| const c1 = createPerson("Maria Johnson", "1970", ""); | |
| const c2 = createPerson("David Johnson", "1972", ""); | |
| addChild(r.id, c1); addChild(r.id, c2); | |
| addChild(c1.id, createPerson("Sofia Brown", "1995", "")); | |
| addChild(c2.id, createPerson("Liam Johnson", "1998", "")); | |
| draw(); | |
| })(); | |
| // small helpers | |
| function downloadTextURL(url, filename) { | |
| const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); | |
| } | |
| function downloadDataURI(uri, filename) { | |
| const a = document.createElement("a"); a.href = uri; a.download = filename; a.click(); | |
| } | |
| // initial draw & resize wiring | |
| resizeCanvas(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |