choaslord2010 commited on
Commit
577bd89
·
verified ·
1 Parent(s): 40b9abf

Upload ai.html

Browse files
Files changed (1) hide show
  1. ai.html +812 -0
ai.html ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Infinite Family Tree Builder</title>
7
+ <style>
8
+ :root{--bg:#f5f7fb;--panel:#ffffff;--accent:#2b6cff;--muted:#6b7280}
9
+ html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;background:var(--bg);color:#111}
10
+ .app {
11
+ display:flex; gap:18px; padding:18px; box-sizing:border-box;
12
+ }
13
+ .panel {
14
+ width:320px; max-height:calc(100vh - 36px); overflow:auto;
15
+ background:var(--panel); border-radius:12px; padding:14px; box-shadow:0 8px 24px rgba(20,30,60,0.08)
16
+ }
17
+ header h1{margin:0;font-size:18px}
18
+ .controls {margin-top:12px; display:grid; gap:8px}
19
+ .controls button, .controls input, .controls select {
20
+ padding:8px 10px; border-radius:8px; border:1px solid #e6e9ef; background:white; font-size:14px
21
+ }
22
+ .info {margin-top:12px; color:var(--muted); font-size:13px}
23
+ #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)}
24
+ canvas {width:100%; height:100%; display:block; border-radius:12px}
25
+ .legend{font-size:13px;color:var(--muted); margin-top:10px}
26
+ .small{font-size:13px}
27
+ .status {margin-top:10px; font-weight:600}
28
+ label.small{display:flex;gap:8px;align-items:center}
29
+ footer {margin-top:12px;color:var(--muted);font-size:13px}
30
+ /* node tooltip/edit */
31
+ #editor {
32
+ 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);
33
+ min-width:260px; display:none; z-index:40;
34
+ }
35
+ #editor input, #editor textarea {width:100%; box-sizing:border-box; padding:6px 8px; border-radius:8px; border:1px solid #e6e9ef; margin-top:6px}
36
+ .node-small {font-size:12px; color:var(--muted)}
37
+ .btn-quiet{background:#f1f5ff;border:1px solid #e6ecff;color:var(--accent)}
38
+ </style>
39
+ </head>
40
+ <body>
41
+
42
+ <div class="app">
43
+ <div class="panel">
44
+ <header>
45
+ <h1>Infinite Family Tree</h1>
46
+ <div class="small">Create, edit & test very large family trees — pan & zoom to explore.</div>
47
+ </header>
48
+
49
+ <div class="controls">
50
+ <button id="newRootBtn">+ Add Root Person</button>
51
+ <div style="display:flex;gap:8px">
52
+ <input id="searchInput" placeholder="Search name..." />
53
+ <button id="searchBtn">Search</button>
54
+ </div>
55
+
56
+ <div style="display:flex;gap:8px">
57
+ <button id="addChildBtn" class="btn-quiet">Add Child</button>
58
+ <button id="delNodeBtn" class="btn-quiet">Delete</button>
59
+ </div>
60
+
61
+ <button id="collapseBtn">Collapse / Expand Selected</button>
62
+
63
+ <div style="display:flex;gap:8px">
64
+ <button id="animatePathBtn">▶ Animate Path (root → selected)</button>
65
+ <button id="centerBtn">Center</button>
66
+ </div>
67
+
68
+ <div style="display:flex;gap:8px">
69
+ <button id="saveBtn">💾 Save JSON</button>
70
+ <button id="loadBtn">📂 Load JSON</button>
71
+ </div>
72
+
73
+ <div style="display:flex;gap:8px">
74
+ <button id="exportBtn">🖼️ Export PNG</button>
75
+ <button id="clearBtn" style="background:#fff7f7;border:1px solid #ffd6d6">Clear All</button>
76
+ </div>
77
+ </div>
78
+
79
+ <div class="info">
80
+ <div>Controls:</div>
81
+ <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>
82
+ </div>
83
+
84
+ <div class="status" id="status">Status: Ready</div>
85
+ <footer>Made for exploring infinite family trees — lightweight, no external libraries.</footer>
86
+ </div>
87
+
88
+ <div id="canvasWrap">
89
+ <canvas id="treeCanvas" width="1600" height="1200"></canvas>
90
+
91
+ <!-- inline editor -->
92
+ <div id="editor">
93
+ <div style="font-weight:700" id="editorTitle">Edit Person</div>
94
+ <label class="small">Name<input id="nameField" /></label>
95
+ <label class="small">Year <input id="yearField" /></label>
96
+ <label class="small">Notes <textarea id="notesField" rows="3"></textarea></label>
97
+ <div style="display:flex;gap:8px;margin-top:8px">
98
+ <button id="saveNodeBtn">Save</button>
99
+ <button id="cancelEditBtn" class="btn-quiet">Cancel</button>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <script>
106
+ /*
107
+ Infinite Family Tree Builder
108
+ - Node structure: { id, name, year, notes, children:[], collapsed:false }
109
+ - Layout: top-down, node width constant; subtree widths sum of child subtree widths.
110
+ - Canvas transform supports pan/zoom. Click/double-click/drag supported.
111
+ */
112
+
113
+ (() => {
114
+ // --- Data model ---
115
+ let nextId = 1;
116
+ let root = null;
117
+ let nodesById = new Map();
118
+
119
+ function createPerson(name="New Person", year="", notes="") {
120
+ const id = String(nextId++);
121
+ const node = { id, name, year, notes, children: [], collapsed:false, parent: null };
122
+ nodesById.set(id, node);
123
+ return node;
124
+ }
125
+
126
+ function addChild(parentId, childNode) {
127
+ const parent = nodesById.get(parentId);
128
+ if (!parent) return;
129
+ parent.children.push(childNode);
130
+ childNode.parent = parentId;
131
+ }
132
+
133
+ function removeNode(id) {
134
+ const node = nodesById.get(id);
135
+ if (!node) return;
136
+ // remove from parent's children
137
+ if (node.parent) {
138
+ const p = nodesById.get(node.parent);
139
+ p.children = p.children.filter(c => c.id !== id);
140
+ } else {
141
+ // root removed
142
+ root = null;
143
+ }
144
+ // recursively delete subtree
145
+ (function del(n) {
146
+ n.children.forEach(c => del(nodesById.get(c.id)));
147
+ nodesById.delete(n.id);
148
+ })(node);
149
+ }
150
+
151
+ // --- View / Canvas ---
152
+ const canvas = document.getElementById("treeCanvas");
153
+ const ctx = canvas.getContext("2d", { alpha:true });
154
+ let viewW = canvas.width = canvas.clientWidth * devicePixelRatio;
155
+ let viewH = canvas.height = canvas.clientHeight * devicePixelRatio;
156
+ canvas.style.width = canvas.clientWidth + "px";
157
+ canvas.style.height = canvas.clientHeight + "px";
158
+ ctx.scale(devicePixelRatio, devicePixelRatio);
159
+
160
+ // pan & zoom
161
+ let panX = 0, panY = 0, zoom = 1;
162
+ let draggingCanvas = false, dragLast = null;
163
+ let spacePan = false;
164
+
165
+ // node rendering params
166
+ const NODE_W = 120, NODE_H = 50, H_SPACING = 20, V_SPACING = 60;
167
+
168
+ // layout cache
169
+ const layoutPositions = new Map(); // id -> {x,y}
170
+
171
+ // selected
172
+ let selectedId = null;
173
+
174
+ // editor elements
175
+ const editor = document.getElementById("editor");
176
+ const nameField = document.getElementById("nameField");
177
+ const yearField = document.getElementById("yearField");
178
+ const notesField = document.getElementById("notesField");
179
+
180
+ // status
181
+ const statusBox = document.getElementById("status");
182
+
183
+ // responsive canvas resize
184
+ function resizeCanvas() {
185
+ // update css size -> keep pixel ratio consistent
186
+ const wrap = document.getElementById("canvasWrap");
187
+ const rect = wrap.getBoundingClientRect();
188
+ canvas.width = Math.floor(rect.width * devicePixelRatio);
189
+ canvas.height = Math.floor(rect.height * devicePixelRatio);
190
+ canvas.style.width = rect.width + "px";
191
+ canvas.style.height = rect.height + "px";
192
+ ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);
193
+ draw();
194
+ }
195
+ window.addEventListener("resize", resizeCanvas);
196
+
197
+ // --- Layout algorithm (simple recursive) ---
198
+ function computeLayout() {
199
+ layoutPositions.clear();
200
+ if (!root) return;
201
+
202
+ // compute subtree width in "units" based on leaf node widths
203
+ function computeWidth(node) {
204
+ if (!node.children.length || node.collapsed) {
205
+ node._subWidth = NODE_W;
206
+ return node._subWidth;
207
+ }
208
+ let sum = 0;
209
+ node.children.forEach(c => sum += computeWidth(nodesById.get(c.id)) + H_SPACING);
210
+ sum = Math.max(sum - H_SPACING, NODE_W);
211
+ node._subWidth = sum;
212
+ return node._subWidth;
213
+ }
214
+
215
+ function place(node, x, y) {
216
+ // x denotes left boundary for this subtree
217
+ const cx = x + (node._subWidth - NODE_W)/2;
218
+ layoutPositions.set(node.id, { x: cx, y });
219
+ if (!node.children.length || node.collapsed) return;
220
+ let curX = x;
221
+ node.children.forEach(c => {
222
+ const child = nodesById.get(c.id);
223
+ place(child, curX, y + NODE_H + V_SPACING);
224
+ curX += child._subWidth + H_SPACING;
225
+ });
226
+ }
227
+
228
+ computeWidth(root);
229
+ // center root in canvas logical coords
230
+ const startX = (canvas.clientWidth - root._subWidth) / 2;
231
+ place(root, startX, 20);
232
+ }
233
+
234
+ // convert world -> screen coords (logical to canvas coords considering pan/zoom)
235
+ function worldToScreen(x,y) {
236
+ return { sx: (x + panX) * zoom, sy: (y + panY) * zoom };
237
+ }
238
+ function screenToWorld(sx,sy) {
239
+ return { x: sx/zoom - panX, y: sy/zoom - panY };
240
+ }
241
+
242
+ // draw function
243
+ function draw() {
244
+ // clear
245
+ ctx.save();
246
+ // Clear with background
247
+ ctx.clearRect(0,0,canvas.width, canvas.height);
248
+ // transform
249
+ ctx.translate(0,0);
250
+ ctx.scale(zoom, zoom);
251
+ ctx.translate(panX, panY);
252
+
253
+ // recompute layout
254
+ computeLayout();
255
+
256
+ // draw edges (parent->child)
257
+ ctx.lineWidth = 2/zoom;
258
+ ctx.strokeStyle = "#cbd5e1";
259
+ ctx.beginPath();
260
+ if (root) {
261
+ nodesById.forEach(node => {
262
+ const pos = layoutPositions.get(node.id);
263
+ if (!pos) return;
264
+ node.children.forEach(c => {
265
+ const childPos = layoutPositions.get(c.id);
266
+ if (!childPos) return;
267
+ // draw a smooth cubic curve
268
+ const x1 = pos.x + NODE_W/2, y1 = pos.y + NODE_H;
269
+ const x2 = childPos.x + NODE_W/2, y2 = childPos.y;
270
+ const mx = (x1 + x2)/2;
271
+ ctx.moveTo(x1, y1);
272
+ ctx.bezierCurveTo(mx, y1 + 10, mx, y2 - 10, x2, y2);
273
+ });
274
+ });
275
+ ctx.stroke();
276
+ }
277
+
278
+ // draw nodes
279
+ nodesById.forEach(node => {
280
+ const pos = layoutPositions.get(node.id);
281
+ if (!pos) return;
282
+ const x = pos.x, y = pos.y;
283
+ const isSelected = node.id === selectedId;
284
+ // node box
285
+ ctx.beginPath();
286
+ roundRect(ctx, x, y, NODE_W, NODE_H, 8);
287
+ if (isSelected) {
288
+ ctx.fillStyle = "#eef2ff";
289
+ ctx.strokeStyle = "#2b6cff";
290
+ ctx.lineWidth = 2/zoom;
291
+ } else {
292
+ ctx.fillStyle = "#ffffff";
293
+ ctx.strokeStyle = "#dbe6ff";
294
+ ctx.lineWidth = 1/zoom;
295
+ }
296
+ ctx.fill();
297
+ ctx.stroke();
298
+
299
+ // collapsed indicator
300
+ if (node.children.length) {
301
+ ctx.beginPath();
302
+ ctx.fillStyle = node.collapsed ? "#ffd7d7" : "#f1f5f9";
303
+ ctx.arc(x + NODE_W - 12, y + 12, 8, 0, Math.PI*2);
304
+ ctx.fill();
305
+ ctx.strokeStyle = "#e6eaf6";
306
+ ctx.stroke();
307
+ ctx.fillStyle = "#444";
308
+ ctx.font = (12/zoom) + "px sans-serif";
309
+ ctx.textAlign = "center";
310
+ ctx.textBaseline = "middle";
311
+ ctx.fillText(node.collapsed ? "+" : "−", x + NODE_W - 12, y + 12);
312
+ }
313
+
314
+ // text: name & year
315
+ ctx.fillStyle = "#0f172a";
316
+ ctx.font = (14/zoom) + "px system-ui, sans-serif";
317
+ ctx.textAlign = "left";
318
+ ctx.textBaseline = "top";
319
+ wrapText(ctx, node.name || "(no name)", x + 8, y + 8, NODE_W - 16, 16/zoom);
320
+
321
+ ctx.fillStyle = "#64748b";
322
+ ctx.font = (12/zoom) + "px sans-serif";
323
+ ctx.fillText(node.year || "", x + 8, y + NODE_H - (16/zoom));
324
+ });
325
+
326
+ ctx.restore();
327
+ }
328
+
329
+ // helper: rounded rect
330
+ function roundRect(ctx, x, y, w, h, r) {
331
+ ctx.moveTo(x + r, y);
332
+ ctx.arcTo(x + w, y, x + w, y + h, r);
333
+ ctx.arcTo(x + w, y + h, x, y + h, r);
334
+ ctx.arcTo(x, y + h, x, y, r);
335
+ ctx.arcTo(x, y, x + w, y, r);
336
+ }
337
+
338
+ // helper: wrap text
339
+ function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
340
+ const words = text.split(/\s+/);
341
+ let line = "", yoff = y;
342
+ for (let n = 0; n < words.length; n++) {
343
+ const testLine = line ? (line + " " + words[n]) : words[n];
344
+ const metrics = ctx.measureText(testLine);
345
+ if (metrics.width > maxWidth && line) {
346
+ ctx.fillText(line, x, yoff);
347
+ line = words[n];
348
+ yoff += lineHeight;
349
+ } else {
350
+ line = testLine;
351
+ }
352
+ }
353
+ if (line) ctx.fillText(line, x, yoff);
354
+ }
355
+
356
+ // hit testing: find node under screen coords
357
+ function findNodeAtScreen(sx, sy) {
358
+ const w = screenToWorld(sx, sy);
359
+ // check nodes in reverse insertion order to prefer later nodes
360
+ let found = null;
361
+ nodesById.forEach(node => {
362
+ const pos = layoutPositions.get(node.id);
363
+ if (!pos) return;
364
+ const x = pos.x, y = pos.y;
365
+ if (w.x >= x && w.x <= x + NODE_W && w.y >= y && w.y <= y + NODE_H) {
366
+ found = node;
367
+ }
368
+ });
369
+ return found;
370
+ }
371
+
372
+ // --- UI wiring ---
373
+ const newRootBtn = document.getElementById("newRootBtn");
374
+ const addChildBtn = document.getElementById("addChildBtn");
375
+ const delNodeBtn = document.getElementById("delNodeBtn");
376
+ const collapseBtn = document.getElementById("collapseBtn");
377
+ const animatePathBtn = document.getElementById("animatePathBtn");
378
+ const centerBtn = document.getElementById("centerBtn");
379
+ const saveBtn = document.getElementById("saveBtn");
380
+ const loadBtn = document.getElementById("loadBtn");
381
+ const exportBtn = document.getElementById("exportBtn");
382
+ const clearBtn = document.getElementById("clearBtn");
383
+ const searchBtn = document.getElementById("searchBtn");
384
+ const searchInput = document.getElementById("searchInput");
385
+
386
+ newRootBtn.addEventListener("click", () => {
387
+ const person = createPerson("Ancestor " + (nextId-1), "", "");
388
+ root = person;
389
+ status("Added root: " + person.name);
390
+ draw();
391
+ });
392
+
393
+ addChildBtn.addEventListener("click", () => {
394
+ if (!selectedId) { status("Select a person first."); return; }
395
+ const child = createPerson("Child " + (nextId-1), "", "");
396
+ addChild(selectedId, child);
397
+ status("Added child to " + nodesById.get(selectedId).name);
398
+ draw();
399
+ });
400
+
401
+ delNodeBtn.addEventListener("click", () => {
402
+ if (!selectedId) { status("Select a node to delete."); return; }
403
+ removeNode(selectedId);
404
+ selectedId = null;
405
+ status("Node deleted.");
406
+ draw();
407
+ });
408
+
409
+ collapseBtn.addEventListener("click", () => {
410
+ if (!selectedId) { status("Select a node."); return; }
411
+ const node = nodesById.get(selectedId);
412
+ node.collapsed = !node.collapsed;
413
+ status(node.collapsed ? "Collapsed subtree." : "Expanded subtree.");
414
+ draw();
415
+ });
416
+
417
+ centerBtn.addEventListener("click", () => {
418
+ centerOnRoot();
419
+ });
420
+
421
+ saveBtn.addEventListener("click", () => {
422
+ if (!root) { status("Nothing to save."); return; }
423
+ const json = JSON.stringify(exportTree(root), null, 2);
424
+ downloadText(json, "family-tree.json");
425
+ status("Saved JSON.");
426
+ });
427
+
428
+ loadBtn.addEventListener("click", () => {
429
+ const input = document.createElement("input");
430
+ input.type = "file";
431
+ input.accept = "application/json";
432
+ input.onchange = e => {
433
+ const file = e.target.files[0];
434
+ if (!file) return;
435
+ const reader = new FileReader();
436
+ reader.onload = evt => {
437
+ try {
438
+ const obj = JSON.parse(evt.target.result);
439
+ importTree(obj);
440
+ status("Loaded JSON.");
441
+ draw();
442
+ } catch (err) {
443
+ status("Failed to load JSON.");
444
+ }
445
+ };
446
+ reader.readAsText(file);
447
+ };
448
+ input.click();
449
+ });
450
+
451
+ exportBtn.addEventListener("click", () => {
452
+ exportPNG();
453
+ });
454
+
455
+ clearBtn.addEventListener("click", () => {
456
+ if (!confirm("Clear the entire tree?")) return;
457
+ root = null; nodesById.clear(); nextId = 1; selectedId = null;
458
+ status("Tree cleared.");
459
+ draw();
460
+ });
461
+
462
+ searchBtn.addEventListener("click", () => {
463
+ const q = (searchInput.value || "").trim().toLowerCase();
464
+ if (!q) return status("Type a name to search.");
465
+ let found = null;
466
+ nodesById.forEach(n => {
467
+ if (!found && n.name.toLowerCase().includes(q)) found = n;
468
+ });
469
+ if (found) {
470
+ selectedId = found.id;
471
+ centerOnNode(found.id);
472
+ status("Found: " + found.name);
473
+ draw();
474
+ } else {
475
+ status("Not found.");
476
+ }
477
+ });
478
+
479
+ // animate path from root to selected (if selected is descendant)
480
+ animatePathBtn.addEventListener("click", () => {
481
+ if (!root) return status("No tree.");
482
+ if (!selectedId) return status("Select a node to animate path to.");
483
+ const path = findPath(root.id, selectedId);
484
+ if (!path) return status("Selected is not a descendant of root.");
485
+ animatePath(path);
486
+ });
487
+
488
+ // find path root->target as array of ids
489
+ function findPath(rootId, targetId) {
490
+ const found = [];
491
+ function dfs(node) {
492
+ if (!node) return false;
493
+ found.push(node.id);
494
+ if (node.id === targetId) return true;
495
+ for (const c of node.children) {
496
+ if (dfs(nodesById.get(c.id))) return true;
497
+ }
498
+ found.pop();
499
+ return false;
500
+ }
501
+ return dfs(nodesById.get(rootId)) ? found.slice() : null;
502
+ }
503
+
504
+ // --- export/import helpers ---
505
+ function exportTree(node) {
506
+ return {
507
+ id: node.id,
508
+ name: node.name, year: node.year, notes: node.notes, collapsed: !!node.collapsed,
509
+ children: node.children.map(c => exportTree(nodesById.get(c.id)))
510
+ };
511
+ }
512
+ function importTree(obj) {
513
+ nodesById.clear();
514
+ nextId = 1;
515
+ function rec(o, parentId=null) {
516
+ const n = createPerson(o.name || "Person", o.year || "", o.notes || "");
517
+ n.id = o.id || n.id;
518
+ // ensure nextId bigger
519
+ nextId = Math.max(nextId, Number(n.id) + 1);
520
+ n.collapsed = !!o.collapsed;
521
+ n.children = [];
522
+ n.parent = parentId;
523
+ nodesById.set(n.id, n);
524
+ (o.children || []).forEach(ch => {
525
+ const child = rec(ch, n.id);
526
+ n.children.push({ id: child.id });
527
+ });
528
+ return n;
529
+ }
530
+ root = rec(obj, null);
531
+ }
532
+
533
+ function downloadText(text, filename) {
534
+ const a = document.createElement("a");
535
+ const blob = new Blob([text], { type: "application/json" });
536
+ a.href = URL.createObjectURL(blob);
537
+ a.download = filename;
538
+ a.click();
539
+ URL.revokeObjectURL(a.href);
540
+ }
541
+
542
+ // --- canvas interactions ---
543
+ canvas.addEventListener("mousedown", e => {
544
+ const rect = canvas.getBoundingClientRect();
545
+ const sx = (e.clientX - rect.left);
546
+ const sy = (e.clientY - rect.top);
547
+ const hit = findNodeAtScreen(sx, sy);
548
+ if (hit) {
549
+ // select
550
+ selectedId = hit.id;
551
+ draw();
552
+ // double-click handled separately for editing
553
+ draggingCanvas = false;
554
+ } else {
555
+ // start pan
556
+ draggingCanvas = true;
557
+ dragLast = { x: e.clientX, y: e.clientY };
558
+ }
559
+ });
560
+
561
+ canvas.addEventListener("mousemove", e => {
562
+ if (draggingCanvas && dragLast) {
563
+ const dx = (e.clientX - dragLast.x) / zoom;
564
+ const dy = (e.clientY - dragLast.y) / zoom;
565
+ panX += dx;
566
+ panY += dy;
567
+ dragLast = { x: e.clientX, y: e.clientY };
568
+ draw();
569
+ }
570
+ });
571
+
572
+ canvas.addEventListener("mouseup", e => {
573
+ draggingCanvas = false; dragLast = null;
574
+ });
575
+ canvas.addEventListener("mouseleave", () => { draggingCanvas = false; dragLast = null; });
576
+
577
+ // zoom with wheel (centered on cursor)
578
+ canvas.addEventListener("wheel", e => {
579
+ e.preventDefault();
580
+ const rect = canvas.getBoundingClientRect();
581
+ const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
582
+ const before = screenToWorld(sx, sy);
583
+ const delta = -e.deltaY * 0.001;
584
+ const newZoom = Math.max(0.2, Math.min(3, zoom * (1 + delta)));
585
+ zoom = newZoom;
586
+ const after = screenToWorld(sx, sy);
587
+ panX += (before.x - after.x);
588
+ panY += (before.y - after.y);
589
+ draw();
590
+ }, { passive:false });
591
+
592
+ // double click -> edit node inline
593
+ canvas.addEventListener("dblclick", e => {
594
+ const rect = canvas.getBoundingClientRect();
595
+ const sx = (e.clientX - rect.left);
596
+ const sy = (e.clientY - rect.top);
597
+ const hit = findNodeAtScreen(sx, sy);
598
+ if (!hit) return;
599
+
600
+ // create an input field overlayed on canvas
601
+ const input = document.createElement("input");
602
+ input.type = "text";
603
+ input.value = hit.name;
604
+ input.style.position = "absolute";
605
+ input.style.left = (e.clientX - 50) + "px";
606
+ input.style.top = (e.clientY - 15) + "px";
607
+ input.style.width = "120px";
608
+ input.style.padding = "4px 6px";
609
+ input.style.fontSize = "14px";
610
+ input.style.border = "1px solid #aaa";
611
+ input.style.borderRadius = "6px";
612
+ input.style.zIndex = 1000;
613
+
614
+ document.body.appendChild(input);
615
+ input.focus();
616
+ input.select();
617
+
618
+ function finish(save) {
619
+ if (save) hit.name = input.value.trim() || "(no name)";
620
+ document.body.removeChild(input);
621
+ draw();
622
+ }
623
+
624
+ input.addEventListener("blur", () => finish(true));
625
+ input.addEventListener("keydown", ev => {
626
+ if (ev.key === "Enter") finish(true);
627
+ if (ev.key === "Escape") finish(false);
628
+ });
629
+ });
630
+
631
+ // keyboard shortcuts
632
+ window.addEventListener("keydown", e => {
633
+ if (e.code === "Space") { spacePan = true; canvas.style.cursor = "grab"; e.preventDefault(); }
634
+ if (e.key === "Delete" || e.key === "Backspace") {
635
+ if (selectedId) {
636
+ if (confirm("Delete selected person and their subtree?")) {
637
+ removeNode(selectedId);
638
+ selectedId = null;
639
+ draw();
640
+ }
641
+ }
642
+ }
643
+ });
644
+ window.addEventListener("keyup", e => {
645
+ if (e.code === "Space") { spacePan = false; canvas.style.cursor = "default"; }
646
+ });
647
+
648
+ // open inline editor
649
+ function openEditor(node) {
650
+ const wrap = document.getElementById("canvasWrap");
651
+ const rect = wrap.getBoundingClientRect();
652
+ const pos = layoutPositions.get(node.id);
653
+ const screen = worldToScreen(pos.x, pos.y);
654
+ // position editor near bottom-left
655
+ editor.style.left = Math.min(rect.width - 280, (screen.sx / zoom) + 10) + "px";
656
+ editor.style.display = "block";
657
+ document.getElementById("editorTitle").textContent = "Edit: " + (node.name || "");
658
+ nameField.value = node.name || "";
659
+ yearField.value = node.year || "";
660
+ notesField.value = node.notes || "";
661
+ editor.dataset.editId = node.id;
662
+ }
663
+
664
+ document.getElementById("saveNodeBtn").addEventListener("click", () => {
665
+ const id = editor.dataset.editId;
666
+ const node = nodesById.get(id);
667
+ if (!node) return;
668
+ node.name = nameField.value.trim() || "(no name)";
669
+ node.year = yearField.value.trim();
670
+ node.notes = notesField.value.trim();
671
+ editor.style.display = "none";
672
+ draw();
673
+ });
674
+ document.getElementById("cancelEditBtn").addEventListener("click", () => {
675
+ editor.style.display = "none";
676
+ });
677
+
678
+ // center on root
679
+ function centerOnRoot() {
680
+ if (!root) return;
681
+ computeLayout();
682
+ const pos = layoutPositions.get(root.id);
683
+ if (!pos) return;
684
+ // center root in view
685
+ const vw = canvas.clientWidth / 2, vh = canvas.clientHeight / 2;
686
+ panX = vw/zoom - pos.x - NODE_W/2;
687
+ panY = vh/zoom - pos.y - NODE_H/2;
688
+ draw();
689
+ }
690
+
691
+ // center on node
692
+ function centerOnNode(id) {
693
+ const pos = layoutPositions.get(id);
694
+ if (!pos) return;
695
+ const vw = canvas.clientWidth / 2, vh = canvas.clientHeight / 2;
696
+ panX = vw/zoom - pos.x - NODE_W/2;
697
+ panY = vh/zoom - pos.y - NODE_H/2;
698
+ draw();
699
+ }
700
+
701
+ // animate path: highlight nodes sequentially and pan to keep them visible
702
+ function animatePath(pathIds) {
703
+ let i = 0;
704
+ const interval = 600; // ms per node
705
+ function step() {
706
+ if (i >= pathIds.length) {
707
+ status("Animation finished.");
708
+ return;
709
+ }
710
+ selectedId = pathIds[i];
711
+ centerOnNode(selectedId);
712
+ draw();
713
+ i++;
714
+ setTimeout(step, interval);
715
+ }
716
+ status("Animating path...");
717
+ step();
718
+ }
719
+
720
+ // export PNG (renders at current zoom/pan)
721
+ function exportPNG() {
722
+ // create an offscreen canvas sized to current view in device pixels
723
+ const rect = canvas.getBoundingClientRect();
724
+ const w = Math.floor(rect.width * devicePixelRatio);
725
+ const h = Math.floor(rect.height * devicePixelRatio);
726
+ const off = document.createElement("canvas");
727
+ off.width = w; off.height = h;
728
+ const octx = off.getContext("2d");
729
+ // scale to device pixels
730
+ octx.scale(devicePixelRatio, devicePixelRatio);
731
+ // draw with same transform
732
+ octx.save();
733
+ octx.scale(zoom, zoom);
734
+ octx.translate(panX, panY);
735
+ // draw background
736
+ octx.fillStyle = "#fff";
737
+ octx.fillRect(0,0,rect.width,rect.height);
738
+ // draw edges
739
+ octx.lineWidth = 2/zoom;
740
+ octx.strokeStyle = "#cbd5e1";
741
+ octx.beginPath();
742
+ nodesById.forEach(node => {
743
+ const pos = layoutPositions.get(node.id); if (!pos) return;
744
+ node.children.forEach(c => {
745
+ const cp = layoutPositions.get(c.id); if (!cp) return;
746
+ const x1 = pos.x + NODE_W/2, y1 = pos.y + NODE_H;
747
+ const x2 = cp.x + NODE_W/2, y2 = cp.y;
748
+ const mx = (x1 + x2)/2;
749
+ octx.moveTo(x1, y1);
750
+ octx.bezierCurveTo(mx, y1 + 10, mx, y2 - 10, x2, y2);
751
+ });
752
+ });
753
+ octx.stroke();
754
+ // draw nodes (basic)
755
+ nodesById.forEach(node => {
756
+ const pos = layoutPositions.get(node.id); if (!pos) return;
757
+ octx.beginPath(); roundRect(octx, pos.x, pos.y, NODE_W, NODE_H, 8);
758
+ octx.fillStyle = "#fff"; octx.fill(); octx.strokeStyle = "#dbe6ff"; octx.stroke();
759
+ octx.fillStyle = "#0f172a";
760
+ octx.font = "14px sans-serif"; octx.textAlign = "left"; octx.textBaseline = "top";
761
+ octx.fillText(node.name||"(no name)", pos.x+8, pos.y+8);
762
+ });
763
+ octx.restore();
764
+ // to dataURL
765
+ const url = off.toDataURL("image/png");
766
+ const a = document.createElement("a");
767
+ a.href = url; a.download = "family-tree.png"; a.click();
768
+ URL.revokeObjectURL(a.href);
769
+ status("PNG exported.");
770
+ }
771
+
772
+ // status helper
773
+ function status(msg) {
774
+ statusBox.textContent = "Status: " + msg;
775
+ }
776
+
777
+ // utility: animate loop to keep redraw if needed
778
+ function tick() {
779
+ requestAnimationFrame(tick);
780
+ // nothing else, redraws are manual
781
+ }
782
+ tick();
783
+
784
+ // initialize with a sample root to help start
785
+ (function initSample() {
786
+ const r = createPerson("Alex Johnson", "1945", "Root ancestor");
787
+ root = r;
788
+ const c1 = createPerson("Maria Johnson", "1970", "");
789
+ const c2 = createPerson("David Johnson", "1972", "");
790
+ addChild(r.id, c1); addChild(r.id, c2);
791
+ addChild(c1.id, createPerson("Sofia Brown", "1995", ""));
792
+ addChild(c2.id, createPerson("Liam Johnson", "1998", ""));
793
+ draw();
794
+ })();
795
+
796
+ // small helpers
797
+ function downloadTextURL(url, filename) {
798
+ const a = document.createElement("a"); a.href = url; a.download = filename; a.click();
799
+ }
800
+
801
+ function downloadDataURI(uri, filename) {
802
+ const a = document.createElement("a"); a.href = uri; a.download = filename; a.click();
803
+ }
804
+
805
+ // initial draw & resize wiring
806
+ resizeCanvas();
807
+
808
+ })();
809
+ </script>
810
+ </body>
811
+ </html>
812
+