Hunyuan3D-2 / ai.html
choaslord2010's picture
Upload ai.html
577bd89 verified
raw
history blame
27.4 kB
<!DOCTYPE html>
<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>