1import { 2 BOARD_PAD_Y, 3 NODE_HEIGHT, 4 NODE_WIDTH, 5 ROW_HEIGHT, 6 buildDeterministicGenealogyGraph 7} from "./layout.js"; 8 9const root = document.getElementById("genealogy-root"); 10 11const GENEALOGY_BUILD_TOKEN = "20260630-genealogy-hover-stable"; 12const DATA_URL = `./data/secure-genealogy.json?v=${GENEALOGY_BUILD_TOKEN}`; 13const TEMPLATE_URLS = { 14 locked: "./stitch/password_entry_page/code.html", 15 treeDesktop: "./stitch/dynamic_family_tree_view/code.html", 16 treeMobile: "./stitch/mobile_family_tree_view/code.html", 17 profileDesktop: "./stitch/member_detail_profile/code.html", 18 profileMobile: "./stitch/mobile_member_profile/code.html", 19 timelineDesktop: "./stitch/main_interactive_timeline/code.html", 20 researchDesktop: "./stitch/speculative_data_hub/code.html" 21}; 22 23const SPOUSE_GAP = 64; 24const HOUSEHOLD_GAP = 140; 25const COMPONENT_GAP = 180; 26const BOARD_PAD_X = 120; 27const FIT_VIEWPORT_PADDING = 32; 28const STAGE_PADDINGS = { 29 desktop: { top: 32, right: 48, bottom: 40, left: 112, railWidth: 88 }, 30 mobile: { top: 24, right: 24, bottom: 24, left: 76, railWidth: 60 } 31}; 32const ZOOM_MIN = 0.04; 33const ZOOM_MAX = 2.5; 34const ZOOM_STEP = 0.18; 35 36const state = { 37 encryptedPayload: null, 38 data: null, 39 graph: null, 40 renderedTree: null, 41 templates: {}, 42 searchQuery: "", 43 searchDisplay: "", 44 activeFamilyFilter: "", 45 activeView: "tree", 46 selectedPersonId: "", 47 passwordVisible: false, 48 lockedTreeHighlight: null, 49 familySidebarCollapsed: false, 50 treeViewport: { 51 scale: 1, 52 displayPercent: null, 53 scrollLeft: 0, 54 scrollTop: 0, 55 restorePending: false, 56 fitPending: false, 57 autoFocusPending: false, 58 focusPeopleIds: [] 59 }, 60 hoverBranchPreview: null 61}; 62 63function escapeHtml(value) { 64 return String(value || "") 65 .replaceAll("&", "&") 66 .replaceAll("<", "<") 67 .replaceAll(">", ">") 68 .replaceAll("\"", """) 69 .replaceAll("'", "'"); 70} 71 72function escapeRegExp(value) { 73 return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 74} 75 76function unique(values) { 77 return [...new Set(values.filter(Boolean))]; 78} 79 80function average(values) { 81 if (!values.length) { 82 return null; 83 } 84 return values.reduce((sum, value) => sum + value, 0) / values.length; 85} 86 87function clamp(value, min, max) { 88 return Math.min(max, Math.max(min, value)); 89} 90 91function currentStageMetrics() { 92 return window.matchMedia("(max-width: 767px)").matches ? STAGE_PADDINGS.mobile : STAGE_PADDINGS.desktop; 93} 94 95function currentStageDimensions() { 96 const graph = state.renderedTree || state.graph; 97 const metrics = currentStageMetrics(); 98 return { 99 ...metrics, 100 width: (graph?.boardWidth || 0) + metrics.left + metrics.right, 101 height: (graph?.boardHeight || 0) + metrics.top + metrics.bottom 102 }; 103} 104 105function truncate(value, length = 180) { 106 const text = String(value || "").replace(/\s+/g, " ").trim(); 107 return text.length > length ? `${text.slice(0, length - 3).trimEnd()}...` : text; 108} 109 110function normalizeSearchText(value) { 111 return String(value || "") 112 .normalize("NFKD") 113 .replace(/[\u0300-\u036f]/g, "") 114 .replace(/[^a-zA-Z0-9\s]/g, " ") 115 .replace(/\s+/g, " ") 116 .trim() 117 .toLowerCase(); 118} 119 120function searchTokens(value) { 121 return normalizeSearchText(value).split(" ").filter(Boolean); 122} 123 124function levenshteinDistance(left, right, maxDistance = 2) { 125 if (left === right) { 126 return 0; 127 } 128 if (Math.abs(left.length - right.length) > maxDistance) { 129 return maxDistance + 1; 130 } 131 const costs = Array.from({ length: right.length + 1 }, (_, index) => index); 132 for (let row = 1; row <= left.length; row += 1) { 133 let previous = row - 1; 134 costs[0] = row; 135 let rowMin = costs[0]; 136 for (let col = 1; col <= right.length; col += 1) { 137 const current = costs[col]; 138 const substitution = left[row - 1] === right[col - 1] ? previous : previous + 1; 139 costs[col] = Math.min( 140 costs[col] + 1, 141 costs[col - 1] + 1, 142 substitution 143 ); 144 previous = current; 145 rowMin = Math.min(rowMin, costs[col]); 146 } 147 if (rowMin > maxDistance) { 148 return maxDistance + 1; 149 } 150 } 151 return costs[right.length]; 152} 153 154function subsequenceMatch(query, candidate) { 155 let pointer = 0; 156 for (const character of candidate) { 157 if (character === query[pointer]) { 158 pointer += 1; 159 if (pointer === query.length) { 160 return true; 161 } 162 } 163 } 164 return false;
165} 166 167function pluralize(count, singular, plural = `${singular}s`) { 168 return `${count} ${count === 1 ? singular : plural}`; 169} 170 171function emphasizeInfoText(value) { 172 const text = String(value || "").replace(/\s+/g, " ").trim(); 173 if (!text) { 174 return ""; 175 } 176 177 const matches = []; 178 const pushMatch = (regex, className, priority = 0) => { 179 regex.lastIndex = 0; 180 for (const result of text.matchAll(regex)) { 181 const matchText = result[0]; 182 if (!matchText) { 183 continue; 184 } 185 matches.push({ 186 start: result.index, 187 end: result.index + matchText.length, 188 className, 189 priority 190 }); 191 } 192 }; 193 194 const searchTerms = unique(searchTokens(state.searchDisplay || state.searchQuery)) 195 .filter((token) => token.length >= 2); 196 for (const term of searchTerms) { 197 pushMatch(new RegExp(`\\b${escapeRegExp(term)}\\w*`, "gi"), "genealogy-inline-match", 3); 198 } 199 200 pushMatch(/\b\d{4}(?:-\d{2}-\d{2})?\b/g, "genealogy-inline-accent", 2); 201 pushMatch(/\b(?:candidate|confirmed|working|unresolved|living|deceased|unknown)\b/gi, "genealogy-inline-accent", 2); 202 pushMatch(/\b(?:born|birth|died|death|married|marriage|burial|residence|migration|moved)\b/gi, "genealogy-inline-accent", 1); 203 pushMatch(/\b(?:mother|father|parent|parents|child|children|spouse|sibling|brother|sister)\b/gi, "genealogy-inline-accent", 1); 204 205 matches.sort((left, right) => 206 left.start - right.start 207 || right.priority - left.priority 208 || (right.end - right.start) - (left.end - left.start) 209 ); 210 211 const accepted = []; 212 for (const match of matches) { 213 if (accepted.some((item) => !(match.end <= item.start || match.start >= item.end))) { 214 continue; 215 } 216 accepted.push(match); 217 } 218 accepted.sort((left, right) => left.start - right.start); 219 220 let cursor = 0; 221 let output = ""; 222 for (const match of accepted) { 223 output += escapeHtml(text.slice(cursor, match.start)); 224 output += `<span class="${match.className}">${escapeHtml(text.slice(match.start, match.end))}</span>`; 225 cursor = match.end; 226 } 227 output += escapeHtml(text.slice(cursor)); 228 return output; 229} 230 231function renderPersonKeyFacts(person, summary) { 232 const facts = []; 233 const usefulTag = (person.tags || []).find((tag) => { 234 const normalized = normalizeSearchText(tag); 235 return normalized && !["genealogy", "family", "record", "archive"].includes(normalized); 236 }); 237 if (person.status && !["confirmed", "working"].includes(String(person.status).toLowerCase())) { 238 facts.push({ label: "Status", value: person.status }); 239 } else if (String(person.life?.death?.label || "").toLowerCase() === "living") { 240 facts.push({ label: "Status", value: "Living" }); 241 } 242 if (person.life?.birth?.place) { 243 facts.push({ label: "Born", value: person.life.birth.place }); 244 } else if (person.life?.death?.place) { 245 facts.push({ label: "Lived", value: person.life.death.place }); 246 } 247 if (summary.parents?.length) { 248 facts.push({ label: "Parents", value: pluralize(summary.parents.length, "linked parent") }); 249 } else if (summary.children?.length) { 250 facts.push({ label: "Children", value: pluralize(summary.children.length, "child", "children") }); 251 } 252 if (usefulTag) { 253 facts.push({ label: "Note", value: usefulTag }); 254 } 255 256 return facts.slice(0, 3).map((fact) => ` 257 <span class="genealogy-key-fact"> 258 <span class="genealogy-key-fact-label">${escapeHtml(fact.label)}</span> 259 <span class="genealogy-key-fact-value">${emphasizeInfoText(fact.value)}</span> 260 </span> 261 `).join(""); 262} 263 264function formatLifeSpan(person) { 265 const birth = person?.life?.birth?.label || "unknown"; 266 const death = person?.life?.death?.label || "living"; 267 return `${birth} - ${death}`; 268} 269 270function getBirthYear(person) { 271 const sortValue = person?.life?.birth?.sortValue; 272 const year = Number.parseInt(String(sortValue || "").slice(0, 4), 10); 273 return Number.isFinite(year) ? year : null; 274} 275 276function personSort(leftPerson, rightPerson) { 277 return (getBirthYear(leftPerson) ?? 9999) - (getBirthYear(rightPerson) ?? 9999) 278 || String(leftPerson?.displayName || "").localeCompare(String(rightPerson?.displayName || ""), "en-GB"); 279} 280
281function familyLabel(person) { 282 return person?.family || "Archive"; 283} 284 285function relationshipNames(ids, peopleById, fallback) { 286 const names = unique(ids).map((id) => peopleById.get(id)?.displayName).filter(Boolean); 287 return names.length ? names.join(", ") : fallback; 288} 289 290function matchesActiveFilters(person) { 291 if (!matchesSearch(person)) { 292 return false; 293 } 294 if (!state.activeFamilyFilter) { 295 return true; 296 } 297 return familyLabel(person) === state.activeFamilyFilter; 298} 299 300function familyHighlightPayload(family) { 301 const peopleIds = (state.graph?.people || []) 302 .filter((person) => familyLabel(person) === family) 303 .map((person) => person.id); 304 const edgeIds = unique( 305 peopleIds.flatMap((personId) => state.graph.connectedEdgeIdsByPerson.get(personId) || []) 306 ); 307 return { 308 key: `family:${family}`, 309 peopleIds, 310 edgeIds 311 }; 312} 313 314function branchPreviewPayload(personId) { 315 if (!state.graph?.peopleById.has(personId)) { 316 return { key: `hover:${personId}`, peopleIds: [], edgeIds: [] }; 317 } 318 const ids = new Set([personId]); 319 const addAncestors = (id) => { 320 for (const parentId of state.graph.summaryById.get(id)?.parents || []) { 321 if (ids.has(parentId)) { 322 continue; 323 } 324 ids.add(parentId); 325 addAncestors(parentId); 326 } 327 }; 328 const addDescendants = (id) => { 329 for (const childId of state.graph.summaryById.get(id)?.children || []) { 330 if (ids.has(childId)) { 331 continue; 332 } 333 ids.add(childId); 334 addDescendants(childId); 335 } 336 }; 337 addAncestors(personId); 338 [...ids].forEach(addDescendants); 339 [...ids].forEach((id) => { 340 for (const spouseId of state.graph.summaryById.get(id)?.spouses || []) { 341 ids.add(spouseId); 342 } 343 }); 344 const peopleIds = [...ids].filter((id) => state.graph.peopleById.has(id)); 345 const edgeIds = unique( 346 peopleIds.flatMap((id) => state.graph.connectedEdgeIdsByPerson.get(id) || []) 347 ); 348 return { 349 key: `hover:${personId}`, 350 peopleIds, 351 edgeIds 352 }; 353} 354 355function datasetHighlightPayload(dataset, fallbackKey = "") { 356 return { 357 key: fallbackKey, 358 peopleIds: String(dataset.highlightPeople || "").split(",").filter(Boolean), 359 edgeIds: String(dataset.highlightEdges || "").split(",").filter(Boolean) 360 }; 361} 362 363function representativeForFamily(family) { 364 const members = (state.graph?.people || []) 365 .filter((person) => familyLabel(person) === family) 366 .sort(personSort); 367 return members.find((person) => resolvedPhotoUrl(person)) || members[0] || null; 368} 369 370function renderHeroFamilyFilters(limit = 3) { 371 return state.graph.familyLegend.slice(0, limit).map((entry) => { 372 const representative = representativeForFamily(entry.family); 373 const isActive = state.activeFamilyFilter === entry.family; 374 return ` 375 <button 376 class="genealogy-hero-filter${isActive ? " is-active" : ""}" 377 type="button" 378 data-family-filter="${escapeHtml(entry.family)}" 379 aria-pressed="${isActive ? "true" : "false"}" 380 > 381 <span class="genealogy-hero-filter-photo"> 382 ${photoMarkup(representative || { displayName: entry.family }, "w-full h-full object-cover")} 383 </span> 384 <span class="genealogy-hero-filter-label">${escapeHtml(entry.family)}</span> 385 </button> 386 `; 387 }).join(""); 388} 389 390function rememberTreeViewport() { 391 const scroller = root.querySelector("[data-tree-scroll]"); 392 if (!scroller) { 393 return; 394 } 395 state.treeViewport.scrollLeft = scroller.scrollLeft; 396 state.treeViewport.scrollTop = scroller.scrollTop; 397} 398 399function restoreTreeViewport() { 400 const scroller = root.querySelector("[data-tree-scroll]"); 401 if (!scroller) { 402 return; 403 } 404 scroller.scrollLeft = state.treeViewport.scrollLeft || 0; 405 scroller.scrollTop = state.treeViewport.scrollTop || 0; 406} 407 408function focusTreeOnPeople(peopleIds, options = {}) { 409 const treeGraph = state.renderedTree || state.graph; 410 const uniqueIds = unique(peopleIds || []); 411 if (!uniqueIds.length) { 412 return; 413 } 414 415 const scroller = root.querySelector("[data-tree-scroll]"); 416 const board = root.querySelector(".genealogy-tree-board"); 417 if (!scroller || !board || !treeGraph) { 418 return; 419 } 420 421 const positions = uniqueIds
422 .map((personId) => treeGraph.positions.get(personId)) 423 .filter(Boolean); 424 if (!positions.length) { 425 return; 426 } 427 428 const boardLeft = Number.parseFloat(board.style.left || "0") || 0; 429 const boardTop = Number.parseFloat(board.style.top || "0") || 0; 430 const minX = Math.min(...positions.map((position) => position.x)); 431 const maxX = Math.max(...positions.map((position) => position.x + NODE_WIDTH)); 432 const minY = Math.min(...positions.map((position) => position.y)); 433 const maxY = Math.max(...positions.map((position) => position.y + NODE_HEIGHT)); 434 const paddingX = Math.max(96, Math.round((maxX - minX) * 0.12)); 435 const paddingY = Math.max(88, Math.round((maxY - minY) * 0.14)); 436 const targetLeft = Math.max(0, boardLeft + minX - paddingX); 437 const targetTop = Math.max(0, boardTop + minY - paddingY); 438 const targetWidth = maxX - minX + paddingX * 2; 439 const targetHeight = maxY - minY + paddingY * 2; 440 441 if (options.fit !== false) { 442 state.treeViewport.scale = clamp( 443 Math.min(scroller.clientWidth / targetWidth, scroller.clientHeight / targetHeight), 444 options.minScale ?? ZOOM_MIN, 445 1 446 ); 447 state.treeViewport.displayPercent = null; 448 applyTreeViewport(); 449 } 450 451 const scale = state.treeViewport.scale || 1; 452 const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth); 453 const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); 454 scroller.scrollLeft = clamp( 455 targetLeft * scale - Math.max(0, (scroller.clientWidth - targetWidth * scale) / 2), 456 0, 457 maxScrollLeft 458 ); 459 scroller.scrollTop = clamp( 460 targetTop * scale - Math.max(0, (scroller.clientHeight - targetHeight * scale) / 2), 461 0, 462 maxScrollTop 463 ); 464} 465 466function formatGenerationYears(ids, peopleById) { 467 const years = ids 468 .map((id) => getBirthYear(peopleById.get(id))) 469 .filter((year) => Number.isFinite(year)); 470 if (!years.length) { 471 return "Undated"; 472 } 473 const first = Math.min(...years); 474 const last = Math.max(...years); 475 return first === last ? String(first) : `${first}-${last}`; 476} 477 478function photoMarkup(person, className) { 479 const safePhoto = resolvedPhotoUrl(person); 480 if (safePhoto) { 481 return `<img class="${className}" src="${escapeHtml(safePhoto)}" alt="${escapeHtml(person.displayName)} portrait">`; 482 } 483 const initials = String(person.displayName || "?") 484 .split(/\s+/) 485 .filter(Boolean) 486 .slice(0, 2) 487 .map((part) => part[0]?.toUpperCase() || "") 488 .join(""); 489 return `<div class="${className} genealogy-photo-placeholder">${escapeHtml(initials || "P")}</div>`; 490} 491 492function resolvedPhotoUrl(person) { 493 const candidate = person?.primaryPhoto && typeof person.primaryPhoto === "object" 494 ? (person.primaryPhoto.previewUrl || person.primaryPhoto.fileUrl || "") 495 : (person?.primaryPhoto || person?.photo || ""); 496 return /^(https?:\/\/|\/|\.\/|\.\.\/)/i.test(String(candidate || "")) ? String(candidate) : ""; 497} 498 499function attachmentTitle(attachment) { 500 return attachment?.title || attachment?.document || attachment?.type || "Archive record"; 501} 502 503function attachmentDateLabel(attachment) { 504 return attachment?.dateLabel || attachment?.date?.label || "Undated"; 505} 506 507function attachmentSummary(attachment) { 508 return truncate(attachment?.relevance || attachment?.summary || "Linked family record.", 150); 509} 510 511function attachmentPersonLabel(attachment) { 512 const names = attachment?.personNames || (attachment?.personName ? [attachment.personName] : []); 513 return names.length ? names.join(", ") : "Archive record"; 514} 515 516function attachmentEvidenceFiles(attachment) { 517 if (Array.isArray(attachment?.evidenceFiles) && attachment.evidenceFiles.length) { 518 return attachment.evidenceFiles 519 .filter((file) => file?.fileUrl) 520 .map((file, index) => ({ 521 title: file.title || `File ${index + 1}`, 522 fileUrl: file.fileUrl, 523 mimeType: file.mimeType || "" 524 })); 525 } 526 if (Array.isArray(attachment?.assetUrls) && attachment.assetUrls.length) { 527 return attachment.assetUrls 528 .filter(Boolean) 529 .map((fileUrl, index) => ({ 530 title: `File ${index + 1}`, 531 fileUrl, 532 mimeType: /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(fileUrl) ? "image" : "" 533 })); 534 } 535 return unique([attachment?.fileUrl, attachment?.previewUrl]) 536 .filter(Boolean) 537 .map((fileUrl, index) => ({ 538 title: index === 0 ? attachmentTitle(attachment) : `File ${index + 1}`, 539 fileUrl, 540 mimeType: /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(fileUrl) ? "image" : "" 541 })); 542} 543 544function isImageEvidenceFile(file) { 545 return /^image\//i.test(file?.mimeType || "") || /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(file?.fileUrl || ""); 546} 547 548function attachmentTranscriptText(attachment) { 549 return String(attachment?.transcript || "").trim(); 550} 551 552function attachmentHasTranscript(attachment) { 553 return Boolean(attachmentTranscriptText(attachment)); 554} 555 556function attachmentTranscriptExcerpt(attachment, length = 260) { 557 return truncate(attachmentTranscriptText(attachment).replace(/\s+/g, " "), length); 558} 559 560function attachmentTranscriptDisclaimer(attachment) { 561 return String( 562 attachment?.transcriptDisclaimer 563 || attachment?.transcriptNotes 564 || "Transcript from the vault. Some words may be interpreted or uncertain where the original document is hard to read." 565 ).trim(); 566} 567 568function attachmentIcon(attachment) { 569 const kind = String(attachment?.kind || attachment?.type || "").toLowerCase(); 570 if (kind.includes("photo") || kind.includes("image") || kind.includes("portrait")) { 571 return "image"; 572 } 573 if (kind.includes("will")) { 574 return "history_edu"; 575 } 576 if (kind.includes("certificate")) { 577 return "badge"; 578 } 579 if (kind.includes("obituary")) { 580 return "article";
581 } 582 if (kind.includes("transcription") || kind.includes("note")) { 583 return "description"; 584 } 585 return "folder_open"; 586} 587 588function attachmentPreviewMarkup(attachment, className = "w-full h-full object-cover") { 589 const evidenceFiles = attachmentEvidenceFiles(attachment); 590 const previewUrl = attachment?.previewUrl 591 || evidenceFiles.find((file) => /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(file.fileUrl))?.fileUrl 592 || attachment?.fileUrl 593 || ""; 594 if (previewUrl && /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(previewUrl)) { 595 return ` 596 <img class="${className}" src="${escapeHtml(previewUrl)}" alt="${escapeHtml(attachmentTitle(attachment))}"> 597 ${evidenceFiles.length > 1 ? `<span class="genealogy-evidence-count">${escapeHtml(pluralize(evidenceFiles.length, "file"))}</span>` : ""} 598 `; 599 } 600 return `<div class="${className} genealogy-attachment-placeholder"><span class="material-symbols-outlined">${escapeHtml(attachmentIcon(attachment))}</span></div>`; 601} 602 603function attachmentEvidenceMarkup(attachment, compact = false) { 604 const evidenceFiles = attachmentEvidenceFiles(attachment); 605 if (evidenceFiles.length <= 1) { 606 return ""; 607 } 608 return ` 609 <div class="genealogy-evidence-files${compact ? " is-compact" : ""}"> 610 ${evidenceFiles.map((file, index) => ` 611 <a class="genealogy-evidence-file-card" href="${escapeHtml(file.fileUrl)}" target="_blank" rel="noreferrer"> 612 <span class="genealogy-evidence-file-media"> 613 ${isImageEvidenceFile(file) 614 ? `<img class="genealogy-evidence-file-image" src="${escapeHtml(file.fileUrl)}" alt="${escapeHtml(file.title)}">` 615 : `<span class="material-symbols-outlined">description</span>`} 616 </span> 617 <span class="genealogy-evidence-file-label">${escapeHtml(compact ? `File ${index + 1}` : file.title)}</span> 618 </a> 619 `).join("")} 620 </div> 621 `; 622} 623 624function attachmentActionLinks(attachment, compact = false) { 625 const links = []; 626 const primaryFileUrl = attachment?.fileUrl || attachmentEvidenceFiles(attachment)[0]?.fileUrl || attachment?.previewUrl || ""; 627 if (primaryFileUrl) { 628 links.push(` 629 <a class="genealogy-record-link" href="${escapeHtml(primaryFileUrl)}" target="_blank" rel="noreferrer"> 630 <span class="material-symbols-outlined">open_in_new</span> 631 ${compact ? "Record" : "Open record"} 632 </a> 633 `); 634 } 635 if (attachment?.transcriptSourceUrl || attachment?.sourceNoteUrls?.[0]) { 636 links.push(` 637 <a class="genealogy-record-link is-secondary" href="${escapeHtml(attachment.transcriptSourceUrl || attachment.sourceNoteUrls[0])}" target="_blank" rel="noreferrer"> 638 <span class="material-symbols-outlined">description</span> 639 ${compact ? "Note" : "Open note"} 640 </a> 641 `); 642 } 643 return links.join(""); 644} 645 646function attachmentTranscriptMarkup(attachment, { compact = false } = {}) { 647 if (!attachmentHasTranscript(attachment)) { 648 return ""; 649 } 650 651 const transcriptSource = attachment?.transcriptSourceTitle || attachment?.sourceNoteTitles?.[0] || ""; 652 return `
653 <section class="genealogy-transcript-shell${compact ? " is-compact" : ""}"> 654 <p class="genealogy-transcript-eyebrow">Readable transcript</p> 655 <p class="genealogy-transcript-excerpt">${escapeHtml(attachmentTranscriptExcerpt(attachment, compact ? 180 : 280))}</p> 656 <p class="genealogy-transcript-disclaimer">${escapeHtml(attachmentTranscriptDisclaimer(attachment))}</p> 657 <details class="genealogy-transcript-details"> 658 <summary>${compact ? "Read transcript" : "Open full transcript"}</summary> 659 ${transcriptSource ? `<p class="genealogy-transcript-source">From ${escapeHtml(transcriptSource)}</p>` : ""} 660 <pre class="genealogy-transcript-text">${escapeHtml(attachmentTranscriptText(attachment))}</pre> 661 </details> 662 </section> 663 `; 664} 665 666function renderAttachmentCard(attachment, { variant = "source", compact = false } = {}) { 667 const noteTitle = (attachment.sourceNoteTitles || [])[0] || ""; 668 const actions = attachmentActionLinks(attachment, compact); 669 const evidenceMarkup = attachmentEvidenceMarkup(attachment, compact); 670 671 if (variant === "record") { 672 return ` 673 <article class="genealogy-record-card"> 674 <div class="genealogy-record-card-preview">${attachmentPreviewMarkup(attachment)}</div> 675 <div class="genealogy-record-card-body"> 676 <p class="genealogy-record-card-title">${escapeHtml(attachmentTitle(attachment))}</p> 677 <p class="genealogy-record-card-meta">${escapeHtml(attachmentPersonLabel(attachment))} | ${escapeHtml(attachmentDateLabel(attachment))}</p> 678 <p class="genealogy-record-card-copy">${escapeHtml(attachmentSummary(attachment))}</p> 679 ${attachmentTranscriptMarkup(attachment, { compact: true })} 680 ${evidenceMarkup} 681 ${actions ? `<div class="genealogy-record-actions">${actions}</div>` : ""} 682 </div> 683 </article> 684 `; 685 } 686 687 const isSource = variant === "source"; 688 return ` 689 <article 690 class="${isSource ? "genealogy-source-card" : "group genealogy-attachment-card"}" 691 style="--family-color:${escapeHtml(attachment.familyColor || "#0041c8")}" 692 > 693 <div class="${isSource ? "genealogy-source-preview" : "genealogy-attachment-card-media"}">${attachmentPreviewMarkup(attachment)}</div> 694 <div class="${isSource ? "genealogy-source-body" : "genealogy-attachment-card-body"}"> 695 <div class="${isSource ? "genealogy-source-topline" : "genealogy-attachment-card-topline"}"> 696 <span class="${isSource ? "genealogy-family-chip" : "genealogy-attachment-kind"}"> 697 ${isSource ? "" : `<span class="material-symbols-outlined">${escapeHtml(attachmentIcon(attachment))}</span>`} 698 ${escapeHtml(isSource ? (attachment.family || "Archive") : (attachment.kind || "record"))} 699 </span> 700 <span class="${isSource ? "genealogy-source-date" : "genealogy-attachment-date"}">${escapeHtml(attachmentDateLabel(attachment))}</span> 701 </div> 702 <h3${isSource ? "" : " class=\"mb-2 text-lg font-bold group-hover:text-primary\""}>${escapeHtml(attachmentTitle(attachment))}</h3> 703 ${isSource ? `<p class="genealogy-source-owner">${escapeHtml(attachmentPersonLabel(attachment))}</p>` : ""} 704 <p class="${isSource ? "genealogy-source-summary" : "text-sm text-on-surface-variant"}">${escapeHtml(attachmentSummary(attachment))}</p> 705 ${noteTitle ? `<p class="genealogy-attachment-note-link">${escapeHtml(noteTitle)}</p>` : ""} 706 ${attachmentTranscriptMarkup(attachment, { compact })} 707 ${evidenceMarkup} 708 ${actions ? `<div class="genealogy-record-actions">${actions}</div>` : ""} 709 </div> 710 </article> 711 `; 712} 713 714function eventTitleText(event) { 715 const baseTitle = event?.title || event?.event || "Family event"; 716 const normalized = normalizeSearchText(baseTitle); 717 const subject = event?.subjectLabel || eventParticipantsLabel(event, state.graph?.peopleById || new Map()); 718 if (!subject || subject === "Archive note" || normalized.includes(subject.toLowerCase())) { 719 return baseTitle; 720 } 721 if (["birth", "death", "burial", "baptism", "residence", "migration", "marriage", "union"].includes(normalized)) { 722 return `${baseTitle} of ${subject}`; 723 } 724 return baseTitle; 725} 726 727function eventSourceLabel(event) { 728 const refs = event?.sourceRefs || []; 729 return refs.length ? refs[0] : (event?.source || ""); 730} 731 732function strongest(order, left, right) { 733 return order.indexOf(left) >= order.indexOf(right) ? left : right; 734} 735 736function hexToRgba(hex, alpha) { 737 const normalized = String(hex || "").trim().replace("#", ""); 738 const value = normalized.length === 3 739 ? normalized.split("").map((character) => `${character}${character}`).join("") 740 : normalized; 741 if (!/^[0-9a-fA-F]{6}$/.test(value)) { 742 return `rgba(0, 65, 200, ${alpha})`; 743 } 744 const red = Number.parseInt(value.slice(0, 2), 16); 745 const green = Number.parseInt(value.slice(2, 4), 16); 746 const blue = Number.parseInt(value.slice(4, 6), 16); 747 return `rgba(${red}, ${green}, ${blue}, ${alpha})`; 748} 749 750function eventIcon(eventName) { 751 const label = String(eventName || "").toLowerCase(); 752 if (label.includes("birth")) { 753 return "child_care"; 754 } 755 if (label.includes("marriage") || label.includes("union")) { 756 return "favorite"; 757 } 758 if (label.includes("migration") || label.includes("move")) { 759 return "travel"; 760 } 761 if (label.includes("residence")) { 762 return "home_pin"; 763 } 764 if (label.includes("death")) { 765 return "local_florist"; 766 } 767 if (label.includes("bapt")) { 768 return "water_drop"; 769 } 770 if (label.includes("sibling")) { 771 return "diversity_1"; 772 } 773 return "history_edu"; 774} 775 776function eventDateLabel(event) { 777 return event?.date?.label || "Undated"; 778} 779 780function eventSortValue(event) { 781 return String(event?.date?.sortValue || "9999-99-99"); 782} 783 784function eventParticipantsLabel(event, peopleById) { 785 if (event?.subjectLabel) { 786 return event.subjectLabel; 787 } 788 const names = (event?.subjectPeople || event?.participants || []).map((id) =>
788 peopleById.get(id)?.displayName).filter(Boolean); 789 return names.length ? names.join(", ") : "Archive note"; 790} 791 792function eventSummaryText(event) { 793 return truncate(event?.summary || event?.notes || event?.source || event?.event || "Recorded family event.", 190); 794} 795 796function personRecordCount(person) { 797 return (person?.attachments || []).filter((attachment) => attachment?.fileUrl || attachment?.previewUrl || (attachment?.sourceNoteUrls || []).length).length; 798} 799 800function attachmentDedupeKey(attachment) { 801 const evidenceKey = attachmentEvidenceFiles(attachment).map((file) => file.fileUrl).sort().join("|"); 802 const sourceKey = (attachment?.sourceNoteTitles || attachment?.vaultNoteIds || []).join("|"); 803 return sourceKey || evidenceKey || attachment?.fileUrl || attachment?.previewUrl || `${attachmentTitle(attachment)}:${attachmentDateLabel(attachment)}`; 804} 805 806function dedupeAttachmentRecords(attachments) { 807 const records = new Map(); 808 for (const attachment of attachments) { 809 const key = attachmentDedupeKey(attachment); 810 if (!records.has(key)) { 811 records.set(key, { 812 ...attachment, 813 personNames: unique([attachment.personName]), 814 personIds: unique([attachment.personId]) 815 }); 816 continue; 817 } 818 const existing = records.get(key); 819 records.set(key, { 820 ...existing, 821 personNames: unique([...(existing.personNames || []), attachment.personName]), 822 personIds: unique([...(existing.personIds || []), attachment.personId]),
823 family: existing.family === attachment.family ? existing.family : "Multiple branches", 824 familyColor: existing.familyColor || attachment.familyColor 825 }); 826 } 827 return [...records.values()]; 828} 829 830function collectAllAttachments({ uniqueRecords = false } = {}) { 831 const attachments = (state.graph?.people || []).flatMap((person) => 832 (person.attachments || []).map((attachment) => ({ 833 ...attachment, 834 personId: person.id, 835 personName: person.displayName, 836 family: person.family, 837 familyColor: person.familyColor 838 })) 839 ); 840 return uniqueRecords ? dedupeAttachmentRecords(attachments) : attachments; 841} 842 843function actualFamilyEntries() { 844 const entries = (state.graph?.familyLegend || []).filter( 845 (entry) => String(entry.family || "").toLowerCase() !== "unresolved" 846 ); 847 return entries.length ? entries : (state.graph?.familyLegend || []); 848} 849 850function speculativeFamilyMembers() { 851 return (state.graph?.people || []).filter((person) => person.status === "candidate" || person.status === "unresolved"); 852} 853 854function buildSelectedFamilyModel() { 855 const family = state.activeFamilyFilter; 856 if (!family || !state.graph) { 857 return null; 858 } 859 860 const corePeople = state.graph.people 861 .filter((person) => familyLabel(person) === family) 862 .sort((left, right) => (state.graph.positions.get(left.id)?.generation ?? 0) - (state.graph.positions.get(right.id)?.generation ?? 0) || personSort(left, right)); 863 if (!corePeople.length) { 864 return null; 865 } 866 867 const coreIds = new Set(corePeople.map((person) => person.id)); 868 const relatedIds = unique( 869 corePeople.flatMap((person) => { 870 const summary = state.graph.summaryById.get(person.id) || {}; 871 return [ 872 person.id, 873 ...(summary.parents || []), 874 ...(summary.children || []), 875 ...(summary.spouses || []) 876 ]; 877 }) 878 ).filter((personId) => state.graph.peopleById.has(personId)); 879 880 const generationLabelByValue = new Map(state.graph.rows.map((row) => [row.generation, row.label])); 881 const membersByGeneration = new Map(); 882 for (const personId of relatedIds) { 883 const person = state.graph.peopleById.get(personId); 884 const generation = state.graph.positions.get(personId)?.generation ?? 0; 885 if (!membersByGeneration.has(generation)) { 886 membersByGeneration.set(generation, []); 887 } 888 membersByGeneration.get(generation).push(person); 889 } 890 891 const generations = [...membersByGeneration.entries()] 892 .sort((left, right) => left[0] - right[0]) 893 .map(([generation, people]) => ({ 894 generation, 895 label: generationLabelByValue.get(generation) || "Undated", 896 people: people.sort((left, right) => { 897 const leftCore = coreIds.has(left.id) ? 0 : 1; 898 const rightCore = coreIds.has(right.id) ? 0 : 1; 899 return leftCore - rightCore || personSort(left, right); 900 }) 901 })); 902 903 const eventMap = new Map(); 904 for (const person of corePeople) { 905 for (const event of state.graph.eventMap.get(person.id) || []) { 906 eventMap.set(event.id, event); 907 } 908 } 909 const events = [...eventMap.values()] 910 .sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right))) 911 .slice(0, 10); 912 const records = corePeople 913 .flatMap((person) => (person.attachments || []).map((attachment) => ({ 914 ...attachment, 915 personId: person.id, 916 personName: person.displayName 917 }))) 918 .filter((attachment) => attachment.fileUrl || attachment.previewUrl || (attachment.sourceNoteUrls || []).length) 919 .slice(0, 8); 920 921 return { 922 family, 923 color: corePeople[0]?.familyColor || "#0041c8", 924 count: corePeople.length, 925 coreIds, 926 relatedIds, 927 generations, 928 events, 929 records, 930 representative: representativeForFamily(family) || corePeople[0] 931 }; 932} 933 934function buildVisibleTreeGraph() { 935 if (!state.graph) { 936 return null; 937 } 938 if (!state.activeFamilyFilter) { 939 return state.graph; 940 } 941 942 const model = buildSelectedFamilyModel(); 943 const visibleIdSet = new Set(model?.relatedIds || []); 944 if (!visibleIdSet.size) { 945 return state.graph; 946 } 947 948 if (state.selectedPersonId) { 949 visibleIdSet.add(state.selectedPersonId); 950 } 951 952 const visiblePeople = state.graph.people.filter((person) => visibleIdSet.has(person.id)); 953 if (!visiblePeople.length) { 954 return state.graph; 955 } 956 957 const visiblePositions = new Map(); 958 let minX = Infinity; 959 let minY = Infinity; 960 let maxX = -Infinity; 961 let maxY = -Infinity; 962 963 for (const person of visiblePeople) { 964 const position = state.graph.positions.get(person.id); 965 if (!position) { 966 continue; 967 } 968 minX = Math.min(minX, position.x); 969 minY = Math.min(minY, position.y); 970 maxX = Math.max(maxX, position.x + NODE_WIDTH); 971 maxY = Math.max(maxY, position.y + NODE_HEIGHT); 972 } 973 974 if (!Number.isFinite(minX) || !Number.isFinite(minY)) { 975 return state.graph; 976 } 977 978 const offsetX = BOARD_PAD_X - minX; 979 const offsetY = BOARD_PAD_Y - minY; 980 for (const person of visiblePeople) { 981 const position = state.graph.positions.get(person.id); 982 if (!position) { 983 continue; 984 } 985 visiblePositions.set(person.id, { 986 ...position, 987 x: position.x + offsetX, 988 y: position.y + offsetY 989 }); 990 } 991 992 const visibleEdges = state.graph.displayEdges.filter((edge) => { 993 const people = edge.people || []; 994 if (!people.length) { 995 return false;
996 } 997 return edge.type === "possible_link" 998 ? people.some((personId) => visibleIdSet.has(personId)) 999 : people.every((personId) => visibleIdSet.has(personId)); 1000 }); 1001 1002 const visibleUnionIds = new Set(visibleEdges.map((edge) => edge.unionId).filter(Boolean)); 1003 const visibleUnionAnchors = new Map( 1004 [...(state.graph.unionAnchors || new Map()).entries()] 1005 .filter(([unionId]) => visibleUnionIds.has(unionId)) 1006 .map(([unionId, anchor]) => [ 1007 unionId, 1008 { 1009 ...anchor, 1010 x: anchor.x + offsetX, 1011 y: anchor.y + offsetY 1012 } 1013 ]) 1014 ); 1015 1016 const visibleRows = [...new Set(visiblePeople.map((person) => visiblePositions.get(person.id)?.generation ?? 0))] 1017 .sort((left, right) => left - right) 1018 .map((generation) => { 1019 const ids = visiblePeople 1020 .filter((person) => (visiblePositions.get(person.id)?.generation ?? 0) === generation) 1021 .map((person) => person.id) 1022 .sort((leftId, rightId) => (visiblePositions.get(leftId)?.x ?? 0) - (visiblePositions.get(rightId)?.x ?? 0)); 1023 const top = Math.min(...ids.map((id) => visiblePositions.get(id)?.y ?? 0), BOARD_PAD_Y); 1024 return { 1025 generation, 1026 ids, 1027 label: formatGenerationYears(ids, state.graph.peopleById), 1028 top 1029 }; 1030 }); 1031 1032 return { 1033 ...state.graph, 1034 people: visiblePeople, 1035 positions: visiblePositions, 1036 rows: visibleRows, 1037 displayEdges: visibleEdges, 1038 unionAnchors: visibleUnionAnchors, 1039 boardWidth: Math.max(Math.round(window.innerWidth * 0.72), Math.round(maxX - minX + BOARD_PAD_X * 2)), 1040 boardHeight: Math.max(Math.round(window.innerHeight * 0.7), Math.round(maxY - minY + BOARD_PAD_Y * 2)) 1041 }; 1042} 1043
1044function renderSelectedFamilyPanel(model, mobile = false) { 1045 if (!model) { 1046 return ""; 1047 } 1048 1049 const isCollapsed = state.familySidebarCollapsed; 1050 const eventMarkup = model.events.length 1051 ? model.events.map((event) => ` 1052 <li class="genealogy-family-timeline-item"> 1053 <span class="genealogy-family-timeline-date">${escapeHtml(eventDateLabel(event))}</span> 1054 <div> 1055 <p class="genealogy-family-timeline-title">${escapeHtml(eventTitleText(event))}</p> 1056 <p class="genealogy-family-timeline-copy">${escapeHtml(truncate(eventSummaryText(event), mobile ? 108 : 140))}</p> 1057 <p class="genealogy-family-timeline-meta">${escapeHtml(eventParticipantsLabel(event, state.graph.peopleById))}${event.placeLabel ? ` ⢠${escapeHtml(event.placeLabel)}` : ""}</p> 1058 </div> 1059 </li> 1060 `).join("") 1061 : `<li class="genealogy-family-timeline-empty">No timeline events are linked to this family yet.</li>`; 1062 const recordMarkup = model.records.length 1063 ? model.records.map((attachment) => renderAttachmentCard(attachment, { variant: "record", compact: true })).join("") 1064 : `<p class="genealogy-family-timeline-empty">No linked record previews are available for this family yet.</p>`; 1065 1066 if (isCollapsed) { 1067 return ` 1068 <aside class="genealogy-family-sidebar genealogy-family-sidebar-collapsed${mobile ? " genealogy-family-sidebar-mobile" : ""}"> 1069 <button class="genealogy-family-sidebar-toggle" type="button" data-action="toggle-family-sidebar" aria-expanded="false"> 1070 <span class="material-symbols-outlined">right_panel_open</span> 1071 <span>${escapeHtml(model.family)} focus</span> 1072 </button> 1073 </aside> 1074 `; 1075 } 1076 1077 const generationsMarkup = model.generations.map((group) => ` 1078 <section class="genealogy-family-mini-generation"> 1079 <div class="genealogy-family-mini-generation-label"> 1080 <span>Gen ${group.generation + 1}</span> 1081 <span>${escapeHtml(group.label)}</span> 1082 </div> 1083 <div class="genealogy-family-mini-generation-grid"> 1084 ${group.people.map((person) => { 1085 const summary = state.graph.summaryById.get(person.id) || { parents: [], children: [], spouses: [] }; 1086 const isCore = model.coreIds.has(person.id); 1087 return ` 1088 <article class="genealogy-family-mini-card${isCore ? " is-core" : " is-related"}" style="--family-color:${escapeHtml(model.color)}; --family-color-soft:${escapeHtml(hexToRgba(model.color, 0.12))};"> 1089 <div class="genealogy-family-mini-card-top"> 1090 <div class="genealogy-family-mini-photo">${photoMarkup(person, "w-full h-full object-cover")}</div> 1091 <button class="genealogy-open-button" type="button" data-person-id="${escapeHtml(person.id)}" data-open-profile="true" aria-label="Open profile for ${escapeHtml(person.displayName)}"> 1092 <span class="material-symbols-outlined text-primary">open_in_new</span> 1093 </button> 1094 </div> 1095 <h4>${escapeHtml(person.displayName)}</h4> 1096 <p class="genealogy-family-mini-life">${escapeHtml(formatLifeSpan(person))}</p> 1097 <dl class="genealogy-family-mini-links"> 1098 <div> 1099 <dt>Parents</dt> 1100 <dd>${escapeHtml(relationshipNames(summary.parents, state.graph.peopleById, "Not linked"))}</dd> 1101 </div> 1102 <div> 1103 <dt>Children</dt> 1104 <dd>${escapeHtml(relationshipNames(summary.children, state.graph.peopleById, "None linked"))}</dd> 1105 </div> 1106 </dl> 1107 </article> 1108 `; 1109 }).join("")} 1110 </div> 1111 </section> 1112 `).join(""); 1113 1114 return ` 1115 <aside class="genealogy-family-sidebar${mobile ? " genealogy-family-sidebar-mobile" : ""}"> 1116 <div class="genealogy-family-sidebar-header"> 1117 <div class="genealogy-family-sidebar-eyebrow" style="--family-color:${escapeHtml(model.color)}"> 1118 <span class="genealogy-legend-dot"></span> 1119 Focused Family 1120 </div> 1121 <button class="genealogy-family-sidebar-toggle" type="button" data-action="toggle-family-sidebar" aria-expanded="true">
1122 <span class="material-symbols-outlined">${mobile ? "expand_less" : "right_panel_close"}</span> 1123 </button> 1124 </div> 1125 <div class="genealogy-family-sidebar-hero"> 1126 <div class="genealogy-family-sidebar-photo">${photoMarkup(model.representative, "w-full h-full object-cover")}</div> 1127 <div> 1128 <h3>${escapeHtml(model.family)}</h3> 1129 <p>${escapeHtml(pluralize(model.count, "person"))} in the current family focus</p> 1130 </div> 1131 </div> 1132 <div class="genealogy-family-sidebar-body"> 1133 <section class="genealogy-family-sidebar-section"> 1134 <div class="genealogy-family-sidebar-section-head"> 1135 <h4>Focused Family Tree</h4> 1136 <p>Parents and children for the selected branch.</p> 1137 </div> 1138 ${generationsMarkup} 1139 </section> 1140 <section class="genealogy-family-sidebar-section"> 1141 <div class="genealogy-family-sidebar-section-head"> 1142 <h4>Family Timeline</h4> 1143 <p>Named events, places, and related people for this branch.</p> 1144 </div> 1145 <ol class="genealogy-family-timeline">${eventMarkup}</ol> 1146 </section> 1147 <section class="genealogy-family-sidebar-section"> 1148 <div class="genealogy-family-sidebar-section-head"> 1149 <h4>Key Records</h4> 1150 <p>Documents, images, and notes linked to this branch.</p> 1151 </div> 1152 <div class="genealogy-record-grid">${recordMarkup}</div> 1153 </section> 1154 </div> 1155 </aside> 1156 `; 1157} 1158 1159function renderTreeWorkspace(mobile = false) { 1160 const familyModel = buildSelectedFamilyModel(); 1161 const sidebarMarkup = familyModel ? renderSelectedFamilyPanel(familyModel, mobile) : ""; 1162 return ` 1163 <div class="genealogy-tree-workspace${familyModel && !state.familySidebarCollapsed && !mobile ? " has-sidebar" : ""}${mobile ? " is-mobile" : ""}"> 1164 <div class="genealogy-tree-main">${renderEnhancedTreeBoard()}</div> 1165 ${sidebarMarkup} 1166 </div> 1167 `; 1168} 1169 1170function renderPrimaryNav() { 1171 const navItems = [ 1172 { view: "tree", label: "Family Tree" }, 1173 { view: "timeline", label: "Timeline" }, 1174 { view: "people", label: "People" }, 1175 { view: "sources", label: "Sources" } 1176 ]; 1177 return navItems.map((item) => { 1178 const isActive = state.activeView === item.view; 1179 const classes = isActive 1180 ? "rounded-full bg-primary text-white shadow-sm" 1181 : "rounded-full bg-white/88 text-on-surface hover:bg-primary/8"; 1182 return ` 1183 <a 1184 href="#" 1185 data-view="${escapeHtml(item.view)}" 1186 class="inline-flex items-center justify-center px-4 py-2 text-sm font-semibold tracking-[0.02em] transition-colors ${classes}" 1187 >${escapeHtml(item.label)}</a> 1188 `; 1189 }).join(""); 1190} 1191 1192function renderPrimarySearchBox(placeholder = "Search lineage...") { 1193 return ` 1194 <div class="genealogy-search-shell"> 1195 <label class="flex items-center gap-3 rounded-[1.35rem] border border-outline-variant/25 bg-white/96 px-4 py-3 shadow-sm"> 1196 <span class="material-symbols-outlined text-outline">search</span> 1197 <input 1198 type="search" 1199 data-role="search" 1200 placeholder="${escapeHtml(placeholder)}" 1201 class="w-full border-0 bg-transparent p-0 text-sm text-on-surface outline-none ring-0 placeholder:text-outline focus:ring-0" 1202 /> 1203 </label> 1204 </div> 1205 `; 1206} 1207 1208function renderArchiveSnapshotCard() { 1209 const familyCount = actualFamilyEntries().length; 1210 const speculativeCount = speculativeFamilyMembers().length; 1211 const attachmentCount = collectAllAttachments().length; 1212 return ` 1213 <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]"> 1214 <p class="mb-4 text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">Archive Snapshot</p> 1215 <div class="grid grid-cols-3 gap-3"> 1216 <div class="rounded-2xl bg-surface-container-low p-4"> 1217 <p class="text-[10px] font-bold uppercase tracking-[0.16em] text-outline">Families</p> 1218 <p class="mt-2 text-3xl font-headline font-black text-on-surface">${escapeHtml(String(familyCount))}</p> 1219 </div> 1220 <div class="rounded-2xl bg-surface-container-low p-4"> 1221 <p class="text-[10px] font-bold uppercase tracking-[0.16em] text-outline">Speculative Members</p> 1222 <p class="mt-2 text-3xl font-headline font-black text-on-surface">${escapeHtml(String(speculativeCount))}</p> 1223 </div> 1224 <div class="rounded-2xl bg-surface-container-low p-4"> 1225 <p class="text-[10px] font-bold uppercase tracking-[0.16em] text-outline">Records</p> 1226 <p class="mt-2 text-3xl font-headline font-black text-on-surface">${escapeHtml(String(attachmentCount))}</p> 1227 </div> 1228 </div> 1229 </section> 1230 `; 1231} 1232 1233function renderPrimaryShell({ view, eyebrow, titleHtml, summaryText, contentMarkup, heroAsideMarkup = "", includeFilters = true }) { 1234 const filtersMarkup = state.graph && includeFilters 1235 ? `<div class="mt-6 genealogy-hero-filters">${renderHeroFamilyFilters(view === "tree" ? 6 : 4)}</div>` 1236 : ""; 1237 root.innerHTML = ` 1238 <div class="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(182,196,255,0.2),_rgba(249,249,249,0)_38%),linear-gradient(180deg,_#fbfbfd_0%,_#f2f4f8_100%)]"> 1239 <header class="sticky top-0 z-40 border-b border-outline-variant/20 bg-white/86 backdrop-blur"> 1240 <div class="mx-auto flex max-w-[1800px] flex-col gap-4 px-4 py-4 md:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8"> 1241 <div class="flex items-center gap-3"> 1242 <div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-sm">
1243 <span class="material-symbols-outlined">family_history</span> 1244 </div> 1245 <div> 1246 <p class="text-[0.7rem] font-black uppercase tracking-[0.28em] text-primary/80">Our Family Tree</p> 1247 <p class="text-sm text-outline">A growing family archive mapped by Amir Mansaray</p> 1248 </div> 1249 </div> 1250 <nav class="flex flex-wrap items-center gap-2"> 1251 ${renderPrimaryNav()} 1252 </nav> 1253 </div> 1254 </header> 1255 <main class="mx-auto flex max-w-[1800px] flex-col gap-6 px-4 py-6 md:px-6 lg:px-8 lg:py-8"> 1256 <section class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(320px,360px)] xl:items-end"> 1257 <div class="rounded-[2rem] border border-outline-variant/20 bg-white/95 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.32)] md:p-8"> 1258 ${eyebrow ? `<p class="text-[10px] font-black uppercase tracking-[0.3em] text-primary/80">${escapeHtml(eyebrow)}</p>` : ""} 1259 <h1 class="mt-3 font-headline text-4xl font-black leading-[0.95] text-on-surface md:text-5xl lg:text-6xl">${titleHtml}</h1> 1260 <p class="mt-4 max-w-3xl text-sm leading-6 text-on-surface-variant md:text-base" data-search-summary="${escapeHtml(view)}">${escapeHtml(summaryText)}</p> 1261 ${filtersMarkup} 1262 </div> 1263 <div class="space-y-4"> 1264 ${renderPrimarySearchBox()} 1265 ${heroAsideMarkup} 1266 </div> 1267 </section> 1268 ${contentMarkup} 1269 </main> 1270 </div> 1271 `; 1272} 1273 1274function dedupeRelationships(relationships) { 1275 const confidenceOrder = ["low", "medium", "high"]; 1276 const statusOrder = ["unresolved", "candidate", "working", "confirmed"]; 1277 const map = new Map(); 1278 for (const relation of relationships) { 1279 const key = relation.type === "parent" 1280 ? `${relation.type}:${relation.from}:${relation.to}` 1281 : `${relation.type}:${[relation.from, relation.to].sort().join(":")}`; 1282 if (!map.has(key)) { 1283 map.set(key, { ...relation, id: key }); 1284 continue; 1285 } 1286 const current = map.get(key); 1287 map.set(key, { 1288 ...current, 1289 ...relation, 1290 id: key, 1291 role: current.type === "parent" && ["father", "mother"].includes(relation.role) ? relation.role : current.role || relation.role, 1292 confidence: strongest(confidenceOrder, current.confidence, relation.confidence), 1293 status: strongest(statusOrder, current.status, relation.status), 1294 notes: unique([...(current.notes || []), ...(relation.notes || [])]), 1295 evidence: unique([...(current.evidence || []), ...(relation.evidence || [])]) 1296 }); 1297 } 1298 return [...map.values()]; 1299} 1300 1301function ownerMatchesPerson(ownerLabel, person) { 1302 const ownerKey = ownerLabel.toLowerCase(); 1303 return String(person?.displayName || "").toLowerCase().startsWith(ownerKey); 1304} 1305 1306function buildConnectedComponents(people, relationships) { 1307 const adjacency = new Map(people.map((person) => [person.id, new Set()])); 1308 for (const relation of relationships) { 1309 if (relation.type === "possible_link") { 1310 continue; 1311 } 1312 if (!adjacency.has(relation.from) || !adjacency.has(relation.to)) { 1313 continue; 1314 } 1315 adjacency.get(relation.from).add(relation.to); 1316 adjacency.get(relation.to).add(relation.from); 1317 } 1318 1319 const pending = new Set(people.map((person) => person.id)); 1320 const components = []; 1321 1322 while (pending.size) { 1323 const start = pending.values().next().value; 1324 pending.delete(start); 1325 const stack = [start]; 1326 const component = []; 1327 while (stack.length) { 1328 const current = stack.pop(); 1329 component.push(current); 1330 for (const next of adjacency.get(current) || []) { 1331 if (!pending.has(next)) { 1332 continue; 1333 } 1334 pending.delete(next); 1335 stack.push(next); 1336 } 1337 } 1338 components.push(component); 1339 } 1340 1341 return components; 1342} 1343 1344function computeGenerationMap(people, relationships, components) { 1345 const years = people.map(getBirthYear).filter((year) => Number.isFinite(year)); 1346 const baseYear = years.length ? Math.min(...years) : 1900; 1347 const yearBuckets = new Map(
1348 people.map((person) => { 1349 const birthYear = getBirthYear(person); 1350 return [person.id, birthYear == null ? null : Math.max(0, Math.floor((birthYear - baseYear) / 26))]; 1351 }) 1352 ); 1353 const generations = new Map(people.map((person) => [person.id, null])); 1354 const parentRelationships = relationships.filter((relation) => relation.type === "parent"); 1355 const linkedGenerationRelationships = relationships.filter((relation) => relation.type === "spouse" || relation.type === "sibling"); 1356 const childIds = new Set(parentRelationships.map((relation) => relation.to)); 1357 const seeds = people.filter((person) => !childIds.has(person.id)).sort(personSort); 1358 if (!seeds.length && people.length) { 1359 seeds.push([...people].sort(personSort)[0]); 1360 } 1361 1362 for (const person of seeds) { 1363 generations.set(person.id, yearBuckets.get(person.id) ?? 0); 1364 } 1365 1366 for (let step = 0; step < 24; step += 1) { 1367 let changed = false; 1368 1369 for (const relation of parentRelationships) { 1370 const parentGeneration = generations.get(relation.from); 1371 const childGeneration = generations.get(relation.to); 1372 1373 if (parentGeneration != null) { 1374 const nextChildGeneration = Math.max(parentGeneration + 1, childGeneration ?? -Infinity, yearBuckets.get(relation.to) ?? -Infinity); 1375 if (childGeneration == null || nextChildGeneration !== childGeneration) { 1376 generations.set(relation.to, nextChildGeneration); 1377 changed = true; 1378 } 1379 } 1380 1381 if (childGeneration != null && parentGeneration == null) { 1382 generations.set(relation.from, Math.max(0, childGeneration - 1)); 1383 changed = true; 1384 } 1385 } 1386 1387 for (const relation of linkedGenerationRelationships) { 1388 const leftGeneration = generations.get(relation.from); 1389 const rightGeneration = generations.get(relation.to); 1390 if (leftGeneration != null && rightGeneration == null) { 1391 generations.set(relation.to, leftGeneration); 1392 changed = true; 1393 } else if (rightGeneration != null && leftGeneration == null) { 1394 generations.set(relation.from, rightGeneration); 1395 changed = true; 1396 } 1397 } 1398 1399 for (const person of people) { 1400 if (generations.get(person.id) == null && yearBuckets.get(person.id) != null) { 1401 generations.set(person.id, yearBuckets.get(person.id)); 1402 changed = true; 1403 } 1404 } 1405 1406 if (!changed) { 1407 break; 1408 } 1409 } 1410 1411 for (const component of components) { 1412 const knownGenerations = component.map((id) => generations.get(id)).filter((value) => value != null); 1413 const fallbackGeneration = knownGenerations.length ? Math.round(average(knownGenerations)) : 0; 1414 for (const personId of component) { 1415 if (generations.get(personId) == null) { 1416 generations.set(personId, fallbackGeneration); 1417 } 1418 } 1419 } 1420 1421 for (let step = 0; step < 12; step += 1) {
1422 let changed = false; 1423 for (const relation of parentRelationships) { 1424 const parentGeneration = generations.get(relation.from); 1425 const childGeneration = generations.get(relation.to); 1426 if (parentGeneration != null && childGeneration != null && childGeneration <= parentGeneration) { 1427 generations.set(relation.to, parentGeneration + 1); 1428 changed = true; 1429 } 1430 } 1431 if (!changed) { 1432 break; 1433 } 1434 } 1435 1436 const orderedValues = [...new Set([...generations.values()].sort((left, right) => left - right))]; 1437 const compressed = new Map(orderedValues.map((value, index) => [value, index])); 1438 for (const person of people) { 1439 generations.set(person.id, compressed.get(generations.get(person.id)) ?? 0); 1440 } 1441 1442 return generations; 1443} 1444 1445function buildHouseholds(people, peopleById, summaryById, generations) { 1446 const consumed = new Set(); 1447 const households = []; 1448 const householdByPersonId = new Map(); 1449 const orderedPeople = [...people].sort((left, right) => (generations.get(left.id) ?? 0) - (generations.get(right.id) ?? 0) || personSort(left, right)); 1450 1451 for (const person of orderedPeople) { 1452 if (consumed.has(person.id)) { 1453 continue; 1454 } 1455 const summary = summaryById.get(person.id); 1456 const spouseId = (summary?.spouses || []).find((candidateId) => { 1457 if (consumed.has(candidateId)) { 1458 return false; 1459 } 1460 return Math.abs((generations.get(candidateId) ?? 0) - (generations.get(person.id) ?? 0)) <= 1; 1461 }); 1462 const members = spouseId ? [person.id, spouseId] : [person.id]; 1463 members.sort((left, right) => personSort(peopleById.get(left), peopleById.get(right))); 1464 const householdId = `household:${members.join(":")}`; 1465 households.push({ 1466 id: householdId, 1467 members, 1468 generation: Math.min(...members.map((memberId) => generations.get(memberId) ?? 0)), 1469 width: members.length * NODE_WIDTH + Math.max(0, members.length - 1) * SPOUSE_GAP 1470 }); 1471 for (const memberId of members) { 1472 householdByPersonId.set(memberId, householdId); 1473 consumed.add(memberId); 1474 } 1475 } 1476 1477 const householdById = new Map(households.map((household) => [household.id, household])); 1478 for (const household of households) { 1479 household.parents = unique( 1480 household.members.flatMap((memberId) => 1481 (summaryById.get(memberId)?.parents || []) 1482 .map((parentId) => householdByPersonId.get(parentId)) 1483 .filter((householdId) => householdId && householdId !== household.id) 1484 ) 1485 ); 1486 household.children = unique( 1487 household.members.flatMap((memberId) => 1488 (summaryById.get(memberId)?.children || []) 1489 .map((childId) => householdByPersonId.get(childId)) 1490 .filter((householdId) => householdId && householdId !== household.id) 1491 ) 1492 ); 1493 } 1494 1495 return { households, householdById, householdByPersonId }; 1496} 1497 1498function buildGraph(data) { 1499 return buildDeterministicGenealogyGraph(data, { 1500 viewportWidth: window.innerWidth || 1600, 1501 viewportHeight: window.innerHeight || 900 1502 }); 1503} 1504 1505function base64ToBytes(value) { 1506 const binary = window.atob(value); 1507 return Uint8Array.from(binary, (character) => character.charCodeAt(0)); 1508} 1509 1510async function decryptPayload(payload, password) { 1511 const encoder = new TextEncoder(); 1512 const keyMaterial = await window.crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]); 1513 const key = await window.crypto.subtle.deriveKey( 1514 { name: "PBKDF2", salt: base64ToBytes(payload.salt), iterations: payload.iterations, hash: payload.digest }, 1515 keyMaterial, 1516 { name: "AES-GCM", length: 256 }, 1517 false, 1518 ["decrypt"] 1519 ); 1520 const encryptedBytes = base64ToBytes(payload.ciphertext); 1521 const combined = new Uint8Array(encryptedBytes.length + 16); 1522 combined.set(encryptedBytes); 1523 combined.set(base64ToBytes(payload.authTag), encryptedBytes.length); 1524 const decrypted = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: base64ToBytes(payload.iv) }, key, combined); 1525 return JSON.parse(new TextDecoder().decode(decrypted)); 1526} 1527 1528async function loadTemplates() { 1529 const entries = await Promise.all(
1530 Object.entries(TEMPLATE_URLS).map(async ([key, url]) => { 1531 const response = await fetch(url, { cache: "no-store" }); 1532 const html = await response.text(); 1533 const documentNode = new DOMParser().parseFromString(html, "text/html"); 1534 return [key, documentNode.body.innerHTML]; 1535 }) 1536 ); 1537 state.templates = Object.fromEntries(entries); 1538} 1539 1540function markNav(rootNode, activeView) { 1541 rootNode.querySelectorAll("a, button").forEach((element) => { 1542 const text = element.textContent.replace(/\s+/g, " ").trim(); 1543 if (text === "Timeline" || text === "History") { 1544 element.dataset.view = "timeline"; 1545 } else if (text === "Family Tree" || text === "Tree" || text === "Lineage") { 1546 element.dataset.view = "tree"; 1547 } else if (text === "People" || text === "Profiles") { 1548 element.dataset.view = "people"; 1549 } else if (text === "Sources" || text === "Records") { 1550 element.dataset.view = "sources"; 1551 } else if (text === "Profile" || text === "account_circle") { 1552 element.dataset.view = "profile"; 1553 } 1554 if (element.dataset.view === activeView) { 1555 element.setAttribute("aria-current", "page"); 1556 } 1557 }); 1558} 1559 1560function selectedPerson() { 1561 return state.graph?.peopleById.get(state.selectedPersonId) || state.graph?.people[0] || null; 1562} 1563 1564function personMatchesSearch(person, rawQuery = state.searchQuery) { 1565 const query = normalizeSearchText(rawQuery); 1566 if (!query) { 1567 return true; 1568 } 1569 const haystack = normalizeSearchText([ 1570 person.displayName, 1571 person.family, 1572 ...(person.tags || []), 1573 person.life?.birth?.place || "", 1574 person.life?.death?.place || "", 1575 ...(person.sources || []), 1576 ...(person.attachments || []).flatMap((attachment) => [ 1577 attachmentTitle(attachment), 1578 attachment?.summary || "", 1579 attachment?.relevance || "", 1580 ...(attachment?.sourceNoteTitles || []) 1581 ]) 1582 ].join(" ")); 1583 if (haystack.includes(query)) { 1584 return true; 1585 } 1586 1587 const candidateTokens = unique([ 1588 ...searchTokens(person.displayName), 1589 ...searchTokens(person.family), 1590 ...(person.tags || []).flatMap((tag) => searchTokens(tag)), 1591 ...searchTokens(person.life?.birth?.place || ""), 1592 ...searchTokens(person.life?.death?.place || ""), 1593 ...(person.sources || []).flatMap((source) => searchTokens(source)), 1594 ...(person.attachments || []).flatMap((attachment) => searchTokens([ 1595 attachmentTitle(attachment), 1596 attachment?.summary || "", 1597 attachment?.relevance || "" 1598 ].join(" "))) 1599 ]); 1600 1601 return searchTokens(query).every((queryToken) => 1602 candidateTokens.some((candidateToken) => 1603 candidateToken === queryToken 1604 || candidateToken.startsWith(queryToken) 1605 || candidateToken.includes(queryToken) 1606 ) 1607 ); 1608} 1609 1610function matchesSearch(person) { 1611 return personMatchesSearch(person, state.searchQuery); 1612} 1613 1614function scoreSearchToken(queryToken, candidateToken) { 1615 if (!queryToken || !candidateToken) { 1616 return 0; 1617 } 1618 if (candidateToken === queryToken) { 1619 return 140; 1620 } 1621 if (candidateToken.startsWith(queryToken)) { 1622 return 112; 1623 } 1624 if (candidateToken.includes(queryToken)) { 1625 return 88; 1626 } 1627 if (queryToken.length >= 3 && subsequenceMatch(queryToken, candidateToken)) { 1628 return 44; 1629 } 1630 if (queryToken.length >= 4) { 1631 const distance = levenshteinDistance(queryToken, candidateToken, queryToken.length >= 7 ? 2 : 1); 1632 if (distance <= 1) { 1633 return 58; 1634 } 1635 if (distance <= 2) { 1636 return 34; 1637 } 1638 } 1639 return 0; 1640} 1641 1642function scorePersonForQuery(person, rawQuery) { 1643 const query = normalizeSearchText(rawQuery); 1644 if (!query) { 1645 return 0; 1646 } 1647 1648 const name = normalizeSearchText(person.displayName); 1649 const family = normalizeSearchText(person.family); 1650 const tokens = unique([ 1651 ...searchTokens(person.displayName), 1652 ...searchTokens(person.family), 1653 ...(person.tags || []).flatMap((tag) => searchTokens(tag)), 1654 ...searchTokens(person.life?.birth?.place || ""), 1655 ...searchTokens(person.life?.death?.place || "") 1656 ]); 1657 const queryParts = searchTokens(query); 1658 let score = 0; 1659 1660 if (name === query) { 1661 score += 420; 1662 } else if (name.startsWith(query)) { 1663 score += 320; 1664 } else if (name.includes(query)) { 1665 score += 250; 1666 } 1667 1668 if (family && (family === query || family.startsWith(query))) { 1669 score += 210; 1670 } 1671 1672 for (const queryToken of queryParts) { 1673 const tokenScore = Math.max(0, ...tokens.map((candidateToken) => scoreSearchToken(queryToken, candidateToken))); 1674 if (tokenScore === 0) { 1675 return 0; 1676 } 1677 score += tokenScore; 1678 } 1679 1680 return score; 1681} 1682 1683function getSearchMatches(query = state.searchQuery) { 1684 const normalizedQuery = normalizeSearchText(query); 1685 if (!normalizedQuery || !state.graph) { 1686 return []; 1687 } 1688 1689 return state.graph.people
1690 .map((person) => ({ person, score: scorePersonForQuery(person, normalizedQuery) })) 1691 .filter((entry) => entry.score > 0) 1692 .sort((left, right) => 1693 right.score - left.score 1694 || personSort(left.person, right.person) 1695 ); 1696} 1697 1698function treeSummaryText(matchesCount) { 1699 return `${state.graph.rows.length} generations | ${actualFamilyEntries().length} families | ${state.graph.people.length} people | ${matchesCount} matches`; 1700} 1701 1702function renderSearchSuggestions(matches) { 1703 if (!matches.length || !state.searchQuery) { 1704 return ""; 1705 } 1706 return matches.slice(0, 8).map(({ person }) => ` 1707 <button 1708 class="genealogy-search-suggestion" 1709 type="button" 1710 data-search-suggestion="${escapeHtml(person.id)}" 1711 > 1712 <span class="genealogy-search-suggestion-photo">${photoMarkup(person, "w-full h-full object-cover")}</span> 1713 <span class="genealogy-search-suggestion-body"> 1714 <span class="genealogy-search-suggestion-name">${escapeHtml(person.displayName)}</span> 1715 <span class="genealogy-search-suggestion-meta">${escapeHtml(familyLabel(person))} | ${escapeHtml(formatLifeSpan(person))}</span> 1716 </span> 1717 </button> 1718 `).join(""); 1719} 1720 1721function updateTreeSearchUI() { 1722 if (state.activeView !== "tree" || !state.graph) { 1723 return false; 1724 } 1725 1726 const search = root.querySelector("[data-role='search']"); 1727 if (!search) { 1728 return false; 1729 } 1730 1731 const matches = getSearchMatches(); 1732 const strictMatchIds = new Set( 1733 state.graph.people 1734 .filter((person) => personMatchesSearch(person, state.searchQuery)) 1735 .map((person) => person.id) 1736 ); 1737 const effectiveMatches = new Set( 1738 state.graph.people 1739 .filter((person) => !state.searchQuery || strictMatchIds.has(person.id)) 1740 .filter((person) => !state.activeFamilyFilter || familyLabel(person) === state.activeFamilyFilter) 1741 .map((person) => person.id) 1742 ); 1743 1744 root.querySelectorAll(".genealogy-tree-node").forEach((node) => { 1745 const personId = node.dataset.treeNode; 1746 const person = state.graph.peopleById.get(personId); 1747 const dimmed = (state.searchQuery || state.activeFamilyFilter) 1748 && person 1749 && !effectiveMatches.has(personId) 1750 && personId !== state.selectedPersonId; 1751 node.classList.toggle("is-search-dimmed", Boolean(dimmed)); 1752 node.classList.toggle("is-search-hit", Boolean(state.searchQuery && strictMatchIds.has(personId))); 1753 }); 1754 1755 const summary = root.querySelector("[data-search-summary]"); 1756 if (summary) { 1757 summary.textContent = treeSummaryText(strictMatchIds.size); 1758 } 1759 1760 const suggestionHost = root.querySelector("[data-search-suggestions]"); 1761 if (suggestionHost) { 1762 suggestionHost.innerHTML = renderSearchSuggestions(matches); 1763 suggestionHost.hidden = !state.searchQuery || !matches.length; 1764 } 1765 1766 return true; 1767} 1768 1769function applySearchSuggestion(personId) { 1770 const person = state.graph?.peopleById.get(personId); 1771 if (!person) { 1772 return; 1773 } 1774 state.searchQuery = normalizeSearchText(person.displayName); 1775 state.searchDisplay = person.displayName; 1776 state.selectedPersonId = person.id; 1777 const search = root.querySelector("[data-role='search']"); 1778 if (search) { 1779 search.value = person.displayName; 1780 } 1781 if (state.activeView !== "tree") { 1782 render(); 1783 return; 1784 } 1785 updateTreeSearchUI(); 1786 const payload = datasetHighlightPayload( 1787 root.querySelector(`[data-tree-node="${person.id}"]`)?.dataset || {}, 1788 `person:${person.id}` 1789 ); 1790 if (payload.peopleIds.length || payload.edgeIds.length) { 1791 state.lockedTreeHighlight = payload; 1792 applyTreeHighlight(payload.peopleIds, payload.edgeIds); 1793 } 1794 focusTreeOnPeople([person.id], { fit: false }); 1795} 1796 1797function bindSearchInput() { 1798 const search = root.querySelector("[data-role='search']"); 1799 if (!search || search.dataset.bound === "true") { 1800 return; 1801 } 1802 1803 search.dataset.bound = "true"; 1804 const shell = search.parentElement; 1805 if (shell) { 1806 shell.classList.add("genealogy-search-shell"); 1807 let suggestionHost = shell.querySelector("[data-search-suggestions]"); 1808 if (!suggestionHost) { 1809 suggestionHost = document.createElement("div"); 1810 suggestionHost.className = "genealogy-search-suggestions"; 1811 suggestionHost.dataset.searchSuggestions = "true"; 1812 suggestionHost.hidden = true; 1813 shell.appendChild(suggestionHost); 1814 } 1815 } 1816 1817 search.setAttribute("autocomplete", "off"); 1818 search.addEventListener("keydown", (event) => { 1819 if (event.key === "Enter") { 1820 const firstSuggestion = root.querySelector("[data-search-suggestion]"); 1821 if (firstSuggestion) { 1822 event.preventDefault(); 1823 applySearchSuggestion(firstSuggestion.dataset.searchSuggestion); 1824 } 1825 } 1826 if (event.key === "Escape") { 1827 state.searchQuery = ""; 1828 state.searchDisplay = ""; 1829 search.value = ""; 1830 updateTreeSearchUI(); 1831 } 1832 }); 1833} 1834 1835function disablePlannedExportButtons() { 1836 root.querySelectorAll("button").forEach((button) => { 1837 const text = String(button.textContent || "").replace(/\s+/g, " ").trim(); 1838 if (/Download Records/i.test(text) || /Export Full Records/i.test(text)) { 1839 button.classList.add("genealogy-disabled-button", "genealogy-disabled-export"); 1840 button.disabled = true; 1841 button.setAttribute("aria-disabled", "true"); 1842 button.title = "Downloads will be added later for Markdown and Excel exports."; 1843 if (/Download Records/i.test(text)) { 1844 button.textContent = "Download Records (Coming Later)"; 1845 } else { 1846 button.innerHTML = `
1847 <span class="material-symbols-outlined">file_download</span> 1848 Export Full Records (Coming Later) 1849 `; 1850 } 1851 } 1852 }); 1853} 1854 1855function connectorLaneY(startY, endY, index = 0, count = 1) { 1856 const minLaneY = startY + 34; 1857 const maxLaneY = Math.max(minLaneY, endY - 38); 1858 if (count <= 1) { 1859 return Math.min(maxLaneY, startY + Math.max(42, Math.min(112, (endY - startY) * 0.36))); 1860 } 1861 const laneStep = Math.min(26, Math.max(12, (maxLaneY - minLaneY) / Math.max(count - 1, 1))); 1862 return Math.min(maxLaneY, minLaneY + index * laneStep); 1863} 1864 1865function edgePath(edge, treeGraph = state.renderedTree || state.graph) { 1866 if (edge.type === "family") { 1867 const parentPositions = edge.parents.map((id) => treeGraph.positions.get(id)).filter(Boolean).sort((left, right) => left.x - right.x); 1868 const childPosition = treeGraph.positions.get(edge.child); 1869 if (!parentPositions.length || !childPosition) { 1870 return ""; 1871 } 1872 const unionAnchor = edge.unionId ? treeGraph.unionAnchors?.get(edge.unionId) : null; 1873 const startX = unionAnchor?.x ?? average(parentPositions.map((position) => position.x + NODE_WIDTH / 2)); 1874 const startY = unionAnchor?.y ?? Math.max(...parentPositions.map((position) => position.y + NODE_HEIGHT)) + 36; 1875 const endX = childPosition.x + NODE_WIDTH / 2; 1876 const endY = childPosition.y; 1877 const laneY = connectorLaneY(startY, endY, edge.childIndex || 0, edge.childCount || 1); 1878 const parentStemPath = edge.showParentStems 1879 ? parentPositions.map((position) => { 1880 const parentX = position.x + NODE_WIDTH / 2; 1881 const parentY = position.y + NODE_HEIGHT; 1882 return `M ${parentX} ${parentY} L ${parentX} ${startY} L ${startX} ${startY}`; 1883 }).join(" ") 1884 : ""; 1885 return `${parentStemPath} M ${startX} ${startY} L ${startX} ${laneY} L ${endX} ${laneY} L ${endX} ${endY}`.trim(); 1886 } 1887 1888 const from = treeGraph.positions.get(edge.from); 1889 const to = treeGraph.positions.get(edge.to); 1890 if (!from || !to) { 1891 return ""; 1892 } 1893 1894 if (edge.type === "spouse") { 1895 const sorted = [from, to].sort((left, right) => left.x - right.x); 1896 const startX = sorted[0].x + NODE_WIDTH; 1897 const endX = sorted[1].x; 1898 const unionAnchor = edge.unionId ? treeGraph.unionAnchors?.get(edge.unionId) : null; 1899 if (unionAnchor) { 1900 const startBottomX = sorted[0].x + NODE_WIDTH / 2; 1901 const endBottomX = sorted[1].x + NODE_WIDTH / 2; 1902 const startBottomY = sorted[0].y + NODE_HEIGHT; 1903 const endBottomY = sorted[1].y + NODE_HEIGHT; 1904 const jointY = Math.max(unionAnchor.y, startBottomY + 28, endBottomY + 28); 1905 return `M ${startBottomX} ${startBottomY} L ${startBottomX} ${jointY} L ${unionAnchor.x} ${jointY} L ${endBottomX} ${jointY} L ${endBottomX} ${endBottomY}`; 1906 } 1907 const y = Math.max(sorted[0].y, sorted[1].y) + NODE_HEIGHT + 28; 1908 return `M ${startX} ${y} C ${startX + 18} ${y}, ${endX - 18} ${y}, ${endX} ${y}`; 1909 } 1910 1911 if (edge.type === "possible_link") { 1912 const startX = from.x + NODE_WIDTH / 2; 1913 const startY = from.y + NODE_HEIGHT / 2; 1914 const endX = to.x + NODE_WIDTH / 2; 1915 const endY = to.y + NODE_HEIGHT / 2; 1916 const controlX = average([startX, endX]); 1917 const controlY = Math.min(startY, endY) - Math.max(36, Math.min(140, Math.abs(endX - startX) * 0.16)); 1918 return `M ${startX} ${startY} C ${controlX} ${controlY}, ${controlX} ${controlY}, ${endX} ${endY}`; 1919 } 1920 1921 const unionAnchor = edge.unionId ? treeGraph.unionAnchors?.get(edge.unionId) : null; 1922 const parentBottomX = from.x + NODE_WIDTH / 2; 1923 const parentBottomY = from.y + NODE_HEIGHT; 1924 const startX = unionAnchor?.x ?? parentBottomX; 1925 const startY = unionAnchor?.y ?? parentBottomY; 1926 const endX = to.x + NODE_WIDTH / 2; 1927 const endY = to.y; 1928 const laneY = connectorLaneY(startY, endY); 1929 const parentStemPath = unionAnchor 1930 ? `M ${parentBottomX} ${parentBottomY} L ${startX} ${startY}` 1931 : `M ${startX} ${startY}`; 1932 return `${parentStemPath} L ${startX} ${laneY} L ${endX} ${laneY} L ${endX} ${endY}`; 1933} 1934 1935function renderEnhancedTreeBoard() { 1936 const treeGraph = buildVisibleTreeGraph(); 1937 state.renderedTree = treeGraph; 1938 const stage = currentStageDimensions(); 1939 const scale = state.treeViewport.scale || 1; 1940 const queryMatchIds = new Set( 1941 treeGraph.people 1942 .filter((person) => personMatchesSearch(person, state.searchQuery))
1943 .map((person) => person.id) 1944 ); 1945 const matchIds = state.activeFamilyFilter 1946 ? new Set(treeGraph.people.map((person) => person.id)) 1947 : new Set(treeGraph.people.filter(matchesActiveFilters).map((person) => person.id)); 1948 const legendMarkup = state.graph.familyLegend.map((entry) => ` 1949 <button 1950 class="genealogy-legend-chip${state.activeFamilyFilter === entry.family ? " is-active" : ""}" 1951 type="button" 1952 data-family-filter="${escapeHtml(entry.family)}" 1953 aria-pressed="${state.activeFamilyFilter === entry.family ? "true" : "false"}" 1954 style="--family-color:${escapeHtml(entry.color)}" 1955 > 1956 <span class="genealogy-legend-dot"></span> 1957 ${escapeHtml(entry.family)} (${entry.count}) 1958 </button> 1959 `).join(""); 1960 const generationMarkup = treeGraph.rows.map((row) => ` 1961 <div class="genealogy-generation-marker">Gen ${row.generation + 1} | ${escapeHtml(row.label)}</div> 1962 `).join(""); 1963 const bandMarkup = treeGraph.rows.map((row) => ` 1964 <div class="genealogy-generation-band" style="top:${(row.top ?? (BOARD_PAD_Y + row.generation * ROW_HEIGHT)) - 26}px;"> 1965 <span class="genealogy-generation-pill">Generation ${row.generation + 1} | ${escapeHtml(row.label)}</span> 1966 </div> 1967 `).join(""); 1968 const edgeMarkup = treeGraph.displayEdges.map((edge) => { 1969 const path = edgePath(edge, treeGraph); 1970 if (!path) { 1971 return ""; 1972 } 1973 const highlightEdgeIds = unique([ 1974 edge.id, 1975 ...(edge.people || []).flatMap((personId) => state.graph.connectedEdgeIdsByPerson.get(personId) || []) 1976 ]); 1977 const edgeColor = edge.type === "possible_link" ? "#8b919d" : edge.color || "#0041c8"; 1978 const klass = edge.type === "spouse" 1979 ? "spouse" 1980 : edge.type === "possible_link" || edge.status === "unresolved" || edge.confidence === "low" 1981 ? "speculative" 1982 : edge.type === "family" 1983 ? "family" 1984 : "parent"; 1985 return ` 1986 <path 1987 class="genealogy-edge ${klass}" 1988 d="${path}" 1989 data-edge-id="${escapeHtml(edge.id)}" 1990 data-highlight-people="${escapeHtml((edge.people || []).join(","))}" 1991 data-highlight-edges="${escapeHtml(highlightEdgeIds.join(","))}" 1992 style="--edge-color:${escapeHtml(edgeColor)}; --edge-color-soft:${escapeHtml(hexToRgba(edgeColor, 0.18))};" 1993 ></path> 1994 `; 1995 }).join(""); 1996 const nodeMarkup = treeGraph.people.map((person) => { 1997 const position = treeGraph.positions.get(person.id); 1998 const summary = state.graph.summaryById.get(person.id); 1999 const dimmed = (state.searchQuery || state.activeFamilyFilter) && !matchIds.has(person.id) && person.id !== state.selectedPersonId 2000 ? " is-search-dimmed" 2001 : ""; 2002 const hit = state.searchQuery && queryMatchIds.has(person.id) ? " is-search-hit" : ""; 2003 const speculative = person.status === "candidate" || person.status === "unresolved"; 2004 const familyColor = person.familyColor || "#0041c8"; 2005 const parentNames = relationshipNames(summary.parents, state.graph.peopleById, "Not linked yet"); 2006 const spouseNames = relationshipNames(summary.spouses, state.graph.peopleById, "No spouse linked"); 2007 const highlightPeople = state.graph.highlightPeopleByPerson.get(person.id) || [person.id]; 2008 const highlightEdges = state.graph.connectedEdgeIdsByPerson.get(person.id) || []; 2009 const keyFactsMarkup = renderPersonKeyFacts(person, summary); 2010 const summaryMarkup = emphasizeInfoText(truncate(person.summary || "No narrative summary recorded yet.", 112)); 2011 const hoverSummaryMarkup = emphasizeInfoText(truncate(person.summary || "This branch is still being assembled from the archive.", 130)); 2012 return ` 2013 <div 2014 class="genealogy-tree-node${dimmed}${hit}" 2015 style="left:${position.x}px; top:${position.y}px; --family-color:${escapeHtml(familyColor)}; --family-color-soft:${escapeHtml(hexToRgba(familyColor, 0.14))}; --family-color-strong:${escapeHtml(hexToRgba(familyColor, 0.3))};" 2016 data-tree-node="${escapeHtml(person.id)}" 2017 data-highlight-people="${escapeHtml(highlightPeople.join(","))}" 2018 data-highlight-edges="${escapeHtml(highlightEdges.join(","))}" 2019 > 2020 <div 2021 class="genealogy-tree-card bg-surface-container-lowest editorial-shadow rounded-xl p-5 text-left border flex flex-col ${speculative ? "border-dashed border-zinc-400" : "border-outline-variant/10"} ${person.id === state.selectedPersonId ? "ring-2 ring-primary" : ""}" 2022 data-tree-select="true" 2023 data-tree-person-id="${escapeHtml(person.id)}" 2024 tabindex="0" 2025 role="button" 2026 aria-pressed="${state.lockedTreeHighlight?.key === `person:${person.id}` ? "true" : "false"}" 2027 > 2028 <div class="flex items-start justify-between gap-3 mb-4"> 2029 <div class="w-16 h-16 rounded-xl overflow-hidden bg-surface-container shadow-sm"> 2030 ${photoMarkup(person, "w-full h-full object-cover")} 2031 </div> 2032 <div class="flex flex-col items-end gap-2">
2033 <span class="genealogy-family-chip">${escapeHtml(familyLabel(person))}</span> 2034 ${speculative ? `<span class="genealogy-status-chip">${escapeHtml(person.status)}</span>` : ""} 2035 </div> 2036 </div> 2037 <h3 class="genealogy-tree-name font-headline text-[1.7rem] font-bold leading-tight mb-2">${escapeHtml(person.displayName)}</h3> 2038 <p class="text-on-surface-variant text-[11px] font-semibold uppercase tracking-[0.18em]">${emphasizeInfoText(formatLifeSpan(person))}</p> 2039 ${keyFactsMarkup ? `<div class="genealogy-key-facts mt-3">${keyFactsMarkup}</div>` : ""} 2040 <p class="genealogy-tree-summary text-sm text-on-surface-variant mt-3 leading-relaxed">${summaryMarkup}</p> 2041 <div class="genealogy-tree-footer mt-auto pt-4 border-t border-outline-variant/10 flex items-center justify-between text-[10px] uppercase tracking-[0.22em] font-bold text-outline"> 2042 <span class="genealogy-tree-counts">${escapeHtml(pluralize(summary.parents.length, "parent"))} | ${escapeHtml(pluralize(summary.children.length, "child", "children"))} | ${escapeHtml(pluralize(personRecordCount(person), "record"))}</span> 2043 <button class="genealogy-open-button" type="button" data-person-id="${escapeHtml(person.id)}" data-open-profile="true" aria-label="Open profile for ${escapeHtml(person.displayName)}"> 2044 <span class="material-symbols-outlined text-primary">open_in_new</span> 2045 </button> 2046 </div> 2047 </div> 2048 <div class="genealogy-hover glass-card rounded-xl p-4 border border-outline-variant/20 shadow-xl"> 2049 <div class="flex items-start gap-3"> 2050 <div class="w-16 h-20 rounded-lg overflow-hidden bg-surface-container">${photoMarkup(person, "w-full h-full object-cover")}</div> 2051 <div class="min-w-0"> 2052 <p class="text-[10px] font-bold uppercase tracking-[0.22em] text-primary">${escapeHtml(familyLabel(person))}</p> 2053 <h4 class="font-headline text-lg font-bold leading-tight mt-1">${escapeHtml(person.displayName)}</h4> 2054 <p class="text-xs text-secondary mt-1">${emphasizeInfoText(formatLifeSpan(person))}</p> 2055 <p class="text-[11px] text-secondary mt-3 leading-relaxed">${hoverSummaryMarkup}</p> 2056 <div class="mt-3 text-[10px] uppercase tracking-[0.18em] text-outline space-y-1"> 2057 <p>Parents: ${emphasizeInfoText(parentNames)}</p> 2058 <p>Spouse: ${emphasizeInfoText(spouseNames)}</p> 2059 </div> 2060 </div> 2061 </div> 2062 </div> 2063 </div> 2064 `; 2065 }).join(""); 2066 return ` 2067 <div class="genealogy-tree-toolbar" style="--legend-offset:${stage.left - 4}px;"> 2068 <div class="genealogy-tree-legend">${legendMarkup}</div> 2069 <div class="genealogy-tree-zoom-controls"> 2070 <button class="genealogy-zoom-button" type="button" data-zoom-action="out" aria-label="Zoom out"> 2071 <span class="material-symbols-outlined">remove</span> 2072 </button> 2073 <button class="genealogy-zoom-button" type="button" data-zoom-action="fit" aria-label="Fit whole tree"> 2074 <span class="material-symbols-outlined">fit_screen</span> 2075 </button> 2076 <button class="genealogy-zoom-button" type="button" data-zoom-action="in" aria-label="Zoom in"> 2077 <span class="material-symbols-outlined">add</span> 2078 </button> 2079 <label class="genealogy-zoom-readout genealogy-zoom-input-shell" aria-label="Zoom percentage"> 2080 <input 2081 class="genealogy-zoom-input" 2082 data-tree-zoom-input 2083 type="number" 2084 min="0" 2085 max="${Math.round(ZOOM_MAX * 100)}" 2086 step="1" 2087 value="${state.treeViewport.displayPercent ?? Math.round(scale * 100)}" 2088 inputmode="numeric" 2089 > 2090 <span class="genealogy-zoom-suffix">%</span> 2091 </label> 2092 </div> 2093 </div> 2094 <div class="genealogy-tree-scroll no-scrollbar" data-tree-scroll> 2095 <div class="genealogy-tree-stage" data-tree-stage data-base-width="${stage.width}" data-base-height="${stage.height}" style="width:${Math.round(stage.width * scale)}px; height:${Math.round(stage.height * scale)}px;"> 2096 <div class="genealogy-tree-panzoom" data-tree-panzoom style="width:${stage.width}px; height:${stage.height}px; transform:scale(${scale});"> 2097 <div class="genealogy-generation-rail" style="top:${stage.top}px; bottom:${stage.bottom}px; width:${stage.railWidth}px;">${generationMarkup}</div> 2098 <div class="genealogy-tree-board" style="left:${stage.left}px; top:${stage.top}px; width:${treeGraph.boardWidth}px; height:${treeGraph.boardHeight}px;"> 2099 <div class="genealogy-generation-bands">${bandMarkup}</div> 2100 <svg viewBox="0 0 ${treeGraph.boardWidth} ${treeGraph.boardHeight}" preserveAspectRatio="xMinYMin meet">${edgeMarkup}</svg> 2101 <div class="genealogy-tree-layer">
2101${nodeMarkup}</div> 2102 </div> 2103 </div> 2104 </div> 2105 </div> 2106 `; 2107} 2108 2109function applyTreeViewport() { 2110 const stage = root.querySelector("[data-tree-stage]"); 2111 const panzoom = root.querySelector("[data-tree-panzoom]"); 2112 const readout = root.querySelector("[data-tree-zoom-value]"); 2113 const input = root.querySelector("[data-tree-zoom-input]"); 2114 if (!stage || !panzoom) { 2115 return; 2116 } 2117 2118 const baseWidth = Number(stage.dataset.baseWidth || 0); 2119 const baseHeight = Number(stage.dataset.baseHeight || 0); 2120 const scale = state.treeViewport.scale || 1; 2121 stage.style.width = `${Math.round(baseWidth * scale)}px`; 2122 stage.style.height = `${Math.round(baseHeight * scale)}px`; 2123 panzoom.style.transform = `scale(${scale})`; 2124 const displayPercent = state.treeViewport.displayPercent ?? Math.round(scale * 100); 2125 if (readout) { 2126 readout.textContent = `${displayPercent}%`; 2127 } 2128 if (input) { 2129 input.value = String(displayPercent); 2130 } 2131} 2132 2133function snapshotTreeViewport() { 2134 const scroller = root.querySelector("[data-tree-scroll]"); 2135 return { 2136 scale: state.treeViewport.scale, 2137 displayPercent: state.treeViewport.displayPercent, 2138 scrollLeft: scroller?.scrollLeft || 0, 2139 scrollTop: scroller?.scrollTop || 0 2140 }; 2141} 2142 2143function restoreTreeViewportSnapshot(snapshot) { 2144 if (!snapshot) { 2145 return; 2146 } 2147 const scroller = root.querySelector("[data-tree-scroll]"); 2148 state.treeViewport.scale = snapshot.scale || 1; 2149 state.treeViewport.displayPercent = snapshot.displayPercent ?? null; 2150 applyTreeViewport(); 2151 if (scroller) { 2152 scroller.scrollLeft = snapshot.scrollLeft || 0; 2153 scroller.scrollTop = snapshot.scrollTop || 0; 2154 } 2155} 2156 2157function fitTreeToViewport(options = {}) { 2158 const scroller = root.querySelector("[data-tree-scroll]"); 2159 const stage = root.querySelector("[data-tree-stage]"); 2160 if (!scroller || !stage) { 2161 return; 2162 } 2163 2164 const baseWidth = Number(stage.dataset.baseWidth || 0); 2165 const baseHeight = Number(stage.dataset.baseHeight || 0); 2166 if (!baseWidth || !baseHeight) { 2167 return; 2168 } 2169 2170 const usableWidth = Math.max(64, scroller.clientWidth - FIT_VIEWPORT_PADDING * 2); 2171 const usableHeight = Math.max(64, scroller.clientHeight - FIT_VIEWPORT_PADDING * 2); 2172 const fitScale = clamp( 2173 Math.min(usableWidth / baseWidth, usableHeight / baseHeight), 2174 ZOOM_MIN, 2175 1 2176 ); 2177 state.treeViewport.scale = fitScale; 2178 state.treeViewport.displayPercent = options.displayPercent ?? null; 2179 applyTreeViewport(); 2180 2181 const scaledWidth = baseWidth * fitScale; 2182 const scaledHeight = baseHeight * fitScale; 2183 scroller.scrollLeft = Math.max(0, (scaledWidth - scroller.clientWidth) / 2); 2184 scroller.scrollTop = Math.max(0, (scaledHeight - scroller.clientHeight) / 2); 2185} 2186 2187function updateTreeScale(nextScale, anchor) { 2188 const scroller = root.querySelector("[data-tree-scroll]"); 2189 if (!scroller) { 2190 return; 2191 } 2192 2193 const currentScale = state.treeViewport.scale || 1; 2194 const scale = clamp(Number(nextScale) || 1, ZOOM_MIN, ZOOM_MAX); 2195 if (Math.abs(scale - currentScale) < 0.001) { 2196 return; 2197 } 2198 2199 const scrollerRect = scroller.getBoundingClientRect(); 2200 const anchorClientX = anchor?.clientX ?? scrollerRect.left + scroller.clientWidth / 2; 2201 const anchorClientY = anchor?.clientY ?? scrollerRect.top + scroller.clientHeight / 2; 2202 const relativeX = anchorClientX - scrollerRect.left; 2203 const relativeY = anchorClientY - scrollerRect.top; 2204 const contentX = (scroller.scrollLeft + relativeX) / currentScale; 2205 const contentY = (scroller.scrollTop + relativeY) / currentScale; 2206 2207 state.treeViewport.scale = scale; 2208 state.treeViewport.displayPercent = null; 2209 applyTreeViewport(); 2210 2211 scroller.scrollLeft = contentX * scale - relativeX; 2212 scroller.scrollTop = contentY * scale - relativeY; 2213} 2214 2215function bindTreeViewport() { 2216 const scroller = root.querySelector("[data-tree-scroll]"); 2217 const board = root.querySelector(".genealogy-tree-board"); 2218 const zoomInput = root.querySelector("[data-tree-zoom-input]"); 2219 if (!scroller || !board) { 2220 return; 2221 } 2222 2223 applyTreeViewport(); 2224 2225 root.querySelectorAll("[data-zoom-action]").forEach((button) => { 2226 button.addEventListener("click", () => { 2227 if (button.dataset.zoomAction === "in") { 2228 updateTreeScale(state.treeViewport.scale + ZOOM_STEP); 2229 return; 2230 } 2231 if (button.dataset.zoomAction === "out") { 2232 updateTreeScale(state.treeViewport.scale - ZOOM_STEP); 2233 return; 2234 } 2235 fitTreeToViewport(); 2236 }); 2237 }); 2238 2239 if (zoomInput) { 2240 const applyZoomInput = () => { 2241 const parsed = Number.parseFloat(String(zoomInput.value || "").replace("%", "").trim()); 2242 if (!Number.isFinite(parsed)) { 2243 applyTreeViewport(); 2244 return; 2245 } 2246 if (parsed <= 0) { 2247 fitTreeToViewport(); 2248 return; 2249 } 2250 updateTreeScale(parsed / 100); 2251 }; 2252 2253 zoomInput.addEventListener("keydown", (event) => { 2254 if (event.key === "Enter") { 2255 event.preventDefault(); 2256 applyZoomInput(); 2257 zoomInput.blur(); 2258 } 2259 }); 2260 zoomInput.addEventListener("blur", applyZoomInput); 2261 } 2262 2263 scroller.addEventListener("wheel", (event) => { 2264 if (!event.ctrlKey && !event.metaKey) { 2265 return; 2266 } 2267 event.preventDefault(); 2268 updateTreeScale(state.treeViewport.scale + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), { 2269 clientX: event.clientX, 2270 clientY: event.clientY 2271 }); 2272 }, { passive: false }); 2273 2274 let drag = null; 2275 const stopDrag = () => { 2276 drag = null; 2277 scroller.classList.remove("is-pointer-down", "is-dragging"); 2278 }; 2279 2280 scroller.addEventListener("pointerdown", (event) => { 2281 if (event.pointerType === "mouse" && event.button !== 0) { 2282 return; 2283 } 2284 if (event.target.closest("[data-tree-node], [data-person-id], [data-open-profile], button, a, input")) { 2285 return; 2286 } 2287 drag = { 2288 pointerId: event.pointerId, 2289 clientX: event.clientX, 2290 clientY: event.clientY, 2291 scrollLeft: scroller.scrollLeft, 2292 scrollTop: scroller.scrollTop 2293 }; 2294 scroller.classList.add("is-pointer-down"); 2295 board.setPointerCapture?.(event.pointerId); 2296 }); 2297 2298 scroller.addEventListener("pointermove", (event) => { 2299 if (!drag || drag.pointerId !== event.pointerId) { 2300 return; 2301 } 2302 const deltaX = event.clientX - drag.clientX; 2303 const deltaY = event.clientY - drag.clientY; 2304 if (Math.abs(deltaX) + Math.abs(deltaY) > 5) { 2305 scroller.classList.add("is-dragging"); 2306 } 2307 scroller.scrollLeft = drag.scrollLeft - deltaX; 2308 scroller.scrollTop = drag.scrollTop - deltaY; 2309 }); 2310 2311 scroller.addEventListener("pointerup", stopDrag); 2312 scroller.addEventListener("pointercancel", stopDrag); 2313 scroller.addEventListener("pointerleave", (event) => { 2314 if (!drag || event.buttons === 0) {
2315 stopDrag(); 2316 } 2317 }); 2318 2319 let pinch = null; 2320 const touchDistance = (touchA, touchB) => Math.hypot(touchA.clientX - touchB.clientX, touchA.clientY - touchB.clientY); 2321 const touchMidpoint = (touchA, touchB) => ({ 2322 clientX: (touchA.clientX + touchB.clientX) / 2, 2323 clientY: (touchA.clientY + touchB.clientY) / 2 2324 }); 2325 2326 scroller.addEventListener("touchstart", (event) => { 2327 if (event.touches.length !== 2) { 2328 return; 2329 } 2330 pinch = { 2331 distance: touchDistance(event.touches[0], event.touches[1]), 2332 scale: state.treeViewport.scale 2333 }; 2334 }, { passive: true }); 2335 2336 scroller.addEventListener("touchmove", (event) => { 2337 if (event.touches.length !== 2 || !pinch) { 2338 return; 2339 } 2340 event.preventDefault(); 2341 const distance = touchDistance(event.touches[0], event.touches[1]); 2342 const midpoint = touchMidpoint(event.touches[0], event.touches[1]); 2343 updateTreeScale(pinch.scale * (distance / pinch.distance), midpoint); 2344 }, { passive: false }); 2345 2346 scroller.addEventListener("touchend", (event) => { 2347 if (event.touches.length < 2) { 2348 pinch = null; 2349 } 2350 }, { passive: true }); 2351 2352 scroller.addEventListener("touchcancel", (event) => { 2353 if (event.touches.length < 2) { 2354 pinch = null; 2355 } 2356 }); 2357} 2358 2359function clearTreeHighlight() { 2360 if (state.lockedTreeHighlight) { 2361 applyTreeHighlight(state.lockedTreeHighlight.peopleIds, state.lockedTreeHighlight.edgeIds); 2362 return; 2363 } 2364 root.querySelectorAll(".genealogy-tree-node").forEach((node) => { 2365 node.classList.remove("is-highlighted", "is-muted"); 2366 }); 2367 root.querySelectorAll(".genealogy-edge").forEach((edge) => { 2368 edge.classList.remove("is-highlighted", "is-muted"); 2369 }); 2370} 2371 2372function applyTreeHighlight(peopleIds, edgeIds) { 2373 const peopleSet = new Set((peopleIds || []).filter(Boolean)); 2374 const edgeSet = new Set((edgeIds || []).filter(Boolean)); 2375 2376 root.querySelectorAll(".genealogy-tree-node").forEach((node) => { 2377 const nodeId = node.dataset.treeNode; 2378 node.classList.toggle("is-highlighted", peopleSet.has(nodeId)); 2379 node.classList.toggle("is-muted", !peopleSet.has(nodeId)); 2380 }); 2381 2382 root.querySelectorAll(".genealogy-edge").forEach((edge) => { 2383 const edgeId = edge.dataset.edgeId; 2384 edge.classList.toggle("is-highlighted", edgeSet.has(edgeId)); 2385 edge.classList.toggle("is-muted", !edgeSet.has(edgeId)); 2386 }); 2387} 2388 2389function activateBranchPreview(personId) { 2390 if (!personId || state.hoverBranchPreview?.personId === personId) { 2391 return; 2392 } 2393 const payload = branchPreviewPayload(personId); 2394 if (payload.peopleIds.length <= 1) { 2395 return; 2396 } 2397 if (!state.hoverBranchPreview) { 2398 state.hoverBranchPreview = { 2399 personId, 2400 snapshot: snapshotTreeViewport() 2401 }; 2402 } else { 2403 state.hoverBranchPreview.personId = personId; 2404 } 2405 root.querySelector(".genealogy-tree-board")?.classList.add("is-branch-preview"); 2406 applyTreeHighlight(payload.peopleIds, payload.edgeIds); 2407 focusTreeOnPeople(payload.peopleIds, { minScale: 0.4 }); 2408} 2409 2410function clearBranchPreview() { 2411 if (!state.hoverBranchPreview) { 2412 clearTreeHighlight(); 2413 return; 2414 } 2415 const { snapshot } = state.hoverBranchPreview; 2416 state.hoverBranchPreview = null; 2417 root.querySelector(".genealogy-tree-board")?.classList.remove("is-branch-preview"); 2418 restoreTreeViewportSnapshot(snapshot); 2419 clearTreeHighlight(); 2420} 2421 2422function bindTreeInteractions() { 2423 const board = root.querySelector(".genealogy-tree-board"); 2424 const scroller = root.querySelector("[data-tree-scroll]"); 2425 if (!board) { 2426 return; 2427 } 2428 scroller?.addEventListener("mouseleave", clearBranchPreview); 2429 2430 const activateFromDataset = (dataset) => { 2431 const { peopleIds, edgeIds } = datasetHighlightPayload(dataset); 2432 if (!peopleIds.length && !edgeIds.length) { 2433 clearTreeHighlight(); 2434 return; 2435 } 2436 applyTreeHighlight(peopleIds, edgeIds); 2437 }; 2438 2439 const toggleLockedHighlight = (payload) => { 2440 if (!payload.peopleIds.length && !payload.edgeIds.length) { 2441 state.lockedTreeHighlight = null; 2442 clearTreeHighlight(); 2443 return; 2444 } 2445 if (state.lockedTreeHighlight?.key === payload.key) { 2446 state.lockedTreeHighlight = null; 2447 clearTreeHighlight(); 2448 return; 2449 } 2450 state.lockedTreeHighlight = payload; 2451 applyTreeHighlight(payload.peopleIds, payload.edgeIds); 2452 }; 2453 2454 board.querySelectorAll("[data-tree-node]").forEach((node) => { 2455 node.addEventListener("mouseenter", () => activateBranchPreview(node.dataset.treeNode)); 2456 node.addEventListener("focusin", () => activateFromDataset(node.dataset)); 2457 node.addEventListener("focusout", (event) => { 2458 if (!node.contains(event.relatedTarget)) { 2459 clearTreeHighlight(); 2460 } 2461 }); 2462 const selectTarget = node.querySelector("[data-tree-select]"); 2463 if (selectTarget) { 2464 selectTarget.addEventListener("click", () => { 2465 state.selectedPersonId = selectTarget.dataset.treePersonId || state.selectedPersonId; 2466 toggleLockedHighlight(datasetHighlightPayload(node.dataset, `person:${state.selectedPersonId}`)); 2467 focusTreeOnPeople([state.selectedPersonId], { fit: false }); 2468 }); 2469 } 2470 }); 2471 2472 board.querySelectorAll("[data-edge-id]").forEach((edge) => { 2473 edge.addEventListener("mouseenter", () => activateFromDataset(edge.dataset)); 2474 edge.addEventListener("mouseleave", clearTreeHighlight); 2475 edge.addEventListener("click", () => { 2476 toggleLockedHighlight(datasetHighlightPayload(edge.dataset, `edge:${edge.dataset.edgeId}`)); 2477 }); 2478 }); 2479} 2480 2481function focusTreeOnSelectedPerson() { 2482 focusTreeOnPeople([state.selectedPersonId], { minScale: 0.75 }); 2483} 2484 2485function queueTreeFocus(peopleIds = []) { 2486 state.treeViewport.focusPeopleIds = unique(peopleIds); 2487 state.treeViewport.restorePending = false;
2488 state.treeViewport.fitPending = false; 2489 state.treeViewport.autoFocusPending = false; 2490} 2491 2492function applyShellBranding() { 2493 document.title = "Our Family Tree"; 2494 2495 const normalizeNodeText = (value) => String(value || "").replace(/\s+/g, " ").trim(); 2496 const leafTextElements = (selector) => [...root.querySelectorAll(selector)].filter((element) => { 2497 if (element.childElementCount > 0) { 2498 return false; 2499 } 2500 return Boolean(normalizeNodeText(element.textContent)); 2501 }); 2502 2503 leafTextElements("div, span, p, h1, h2, h3, h4, h5, a, button").forEach((element) => { 2504 const text = normalizeNodeText(element.textContent); 2505 if (!text) { 2506 return; 2507 } 2508 if (text === "The Digital Archivist" || text === "Digital Archivist") { 2509 element.textContent = "Our Family Tree"; 2510 return; 2511 } 2512 if (text.includes("The Digital Archivist")) { 2513 element.textContent = text.replaceAll("The Digital Archivist", "Our Family Tree"); 2514 return; 2515 } 2516 if (text === "Family Repository") { 2517 element.textContent = "Our Family Tree"; 2518 return; 2519 } 2520 if (text === "Verified Records") { 2521 element.textContent = "Private Archive"; 2522 } 2523 }); 2524 2525 root.querySelectorAll("button").forEach((button) => { 2526 const text = normalizeNodeText(button.textContent); 2527 if (/Add Record/i.test(text)) { 2528 button.remove(); 2529 } 2530 }); 2531 2532 const nav = root.querySelector("header nav"); 2533 if (nav) { 2534 const links = [...nav.querySelectorAll("a")]; 2535 if (links[0]) { 2536 links[0].textContent = "Family Tree"; 2537 links[0].dataset.view = "tree"; 2538 links[0].href = "#"; 2539 } 2540 if (links[1]) { 2541 links[1].textContent = "Timeline"; 2542 links[1].dataset.view = "timeline"; 2543 links[1].href = "#"; 2544 } 2545 if (links[2]) { 2546 links[2].textContent = "People"; 2547 links[2].dataset.view = "people"; 2548 links[2].href = "#"; 2549 } 2550 if (!nav.querySelector("[data-view='sources']")) { 2551 const sourceLink = document.createElement("a"); 2552 sourceLink.className = links[0]?.className || "text-zinc-500 hover:text-zinc-900 transition-colors"; 2553 sourceLink.href = "#"; 2554 sourceLink.dataset.view = "sources"; 2555 sourceLink.textContent = "Sources"; 2556 nav.appendChild(sourceLink); 2557 } 2558 } 2559 2560 root.querySelectorAll("a").forEach((link) => { 2561 const text = normalizeNodeText(link.textContent); 2562 if (["Privacy", "Terms", "Legal Documentation", "Need the password?"].includes(text)) { 2563 link.removeAttribute("href"); 2564 link.classList.add("genealogy-muted-link"); 2565 } 2566 }); 2567 2568 root.querySelectorAll("label").forEach((label) => { 2569 const text = normalizeNodeText(label.textContent); 2570 if (text.includes("Keep me signed in")) { 2571 label.classList.add("genealogy-hidden"); 2572 } 2573 }); 2574 2575 leafTextElements("div, p, span").forEach((element) => { 2576 const text = normalizeNodeText(element.textContent); 2577 if (text.includes("Verified Records")) { 2578 element.textContent = "Family Archive"; 2579 } 2580 if (text.includes("branches ready to explore") && state.encryptedPayload?.summary) { 2581 element.textContent = `${state.encryptedPayload.summary.personCount} people, ${state.encryptedPayload.summary.eventCount} events, and ${state.encryptedPayload.summary.familyCount || state.encryptedPayload.summary.branchCount} families ready to explore.`; 2582 } 2583 }); 2584} 2585 2586function renderTreeBoard() { 2587 return renderEnhancedTreeBoard(); 2588 2589 const matchIds = new Set(state.graph.people.filter(matchesSearch).map((person) => person.id)); 2590 const generationMarkup = state.graph.rows.map((row) => { 2591 const years = row.ids.map((id) => getBirthYear(state.graph.peopleById.get(id))).filter((year) => Number.isFinite(year)); 2592 const label = years.length ? `${Math.min(...years)}${Math.max(...years) !== Math.min(...years) ? `-${Math.max(...years)}` : ""}` : "Unknown"; 2593 return `<div class="genealogy-generation-marker">Gen ${row.generation + 1} · ${escapeHtml(label)}</div>`; 2594 }).join(""); 2595 const edgeMarkup = state.graph.relationships.map((relation) => { 2596 if (!["parent", "spouse", "possible_link"].includes(relation.type)) { 2597 return ""; 2598 } 2599 const from = state.graph.positions.get(relation.from); 2600 const to = state.graph.positions.get(relation.to); 2601 if (!from || !to) { 2602 return ""; 2603 }
2604 const klass = relation.type === "spouse" ? "spouse" : relation.type === "possible_link" || relation.status === "unresolved" || relation.confidence === "low" ? "speculative" : "parent"; 2605 return `<path class="genealogy-edge ${klass}" d="${edgePath(from, to, relation.type)}"></path>`; 2606 }).join(""); 2607 const nodeMarkup = state.graph.people.map((person) => { 2608 const position = state.graph.positions.get(person.id); 2609 const summary = state.graph.summaryById.get(person.id); 2610 const dimmed = state.searchQuery && !matchIds.has(person.id) && person.id !== state.selectedPersonId ? " is-dimmed" : ""; 2611 const speculative = person.status === "candidate" || person.status === "unresolved"; 2612 return ` 2613 <div class="genealogy-tree-node${dimmed}" style="left:${position.x}px; top:${position.y}px;"> 2614 <button class="bg-surface-container-lowest editorial-shadow rounded-xl p-5 text-left border ${speculative ? "border-dashed border-zinc-400" : "border-outline-variant/10"} ${person.id === state.selectedPersonId ? "ring-2 ring-primary" : ""} hover:-translate-y-1 transition-all" data-person-id="${escapeHtml(person.id)}" data-open-profile="true"> 2615 <div class="flex items-start justify-between mb-4"> 2616 <div class="w-14 h-14 rounded-lg overflow-hidden bg-surface-container"> 2617 ${photoMarkup(person, "w-full h-full object-cover")} 2618 </div> 2619 <span class="bg-primary/10 text-primary text-[10px] font-black px-2 py-1 rounded uppercase tracking-widest">${escapeHtml(speculative ? person.status : person.family || "Record")}</span> 2620 </div> 2621 <h3 class="font-headline text-xl font-bold mb-1">${escapeHtml(person.displayName)}</h3> 2622 <p class="text-on-surface-variant text-xs font-medium uppercase tracking-tighter">${escapeHtml(formatLifeSpan(person))}</p> 2623 <div class="mt-4 pt-4 border-t border-outline-variant/10 flex items-center justify-between"> 2624 <span class="text-[10px] text-outline uppercase font-bold tracking-widest">${escapeHtml(`${summary.parents.length} parents · ${summary.children.length} children`)}</span> 2625 <span class="material-symbols-outlined text-primary">open_in_new</span> 2626 </div> 2627 </button> 2628 <div class="genealogy-hover glass-card rounded-xl p-4 border border-outline-variant/20 shadow-xl"> 2629 <div class="flex items-start gap-3"> 2630 <div class="w-16 h-20 rounded-lg overflow-hidden bg-surface-container">${photoMarkup(person, "w-full h-full object-cover")}</div> 2631 <div class="min-w-0"> 2632 <p class="text-[10px] font-bold uppercase tracking-widest text-primary">${escapeHtml(person.family || "Archive record")}</p> 2633 <h4 class="font-headline text-lg font-bold leading-tight">${escapeHtml(person.displayName)}</h4> 2634 <p class="text-xs text-secondary mt-1">${escapeHtml(formatLifeSpan(person))}</p> 2635 <p class="text-sm text-secondary mt-3">${escapeHtml(truncate(person.summary || "No biography summary recorded yet.", 130))}</p> 2636 </div> 2637 </div> 2638 </div> 2639 </div> 2640 `; 2641 }).join(""); 2642 return ` 2643 <div class="genealogy-tree-scroll no-scrollbar"> 2644 <div class="genealogy-tree-stage"> 2645 <div class="genealogy-generation-rail">${generationMarkup}</div> 2646 <div class="genealogy-tree-board" style="width:${state.graph.boardWidth}px; height:${state.graph.boardHeight}px;"> 2647 <svg viewBox="0 0 ${state.graph.boardWidth} ${state.graph.boardHeight}">${edgeMarkup}</svg> 2648 <div class="genealogy-tree-layer">${nodeMarkup}</div> 2649 </div> 2650 </div> 2651 </div> 2652 `; 2653} 2654 2655function renderLockedView() { 2656 root.innerHTML = `<div class="genealogy-template-root">${state.templates.locked}</div>`; 2657 const form = root.querySelector("form"); 2658 const input = root.querySelector("#password"); 2659 if (form) { 2660 form.dataset.role = "unlock-form"; 2661 } 2662 if (input) { 2663 input.id = "archive-password"; 2664 input.placeholder = "Enter family password"; 2665 input.value = ""; 2666 input.type = state.passwordVisible ? "text" : "password"; 2667 } 2668 const intro = root.querySelector("p.text-secondary.text-sm"); 2669 if (intro && state.encryptedPayload?.summary) { 2670 intro.textContent = `${state.encryptedPayload.summary.personCount} people, ${state.encryptedPayload.summary.eventCount} events, ${state.encryptedPayload.summary.familyCount || state.encryptedPayload.summary.branchCount} families. Enter the family password to continue.`; 2671 } 2672 const toggle = root.querySelector("[data-icon='visibility']"); 2673 if (toggle) { 2674 toggle.dataset.action = "toggle-password"; 2675 } 2676 const navLinks = root.querySelectorAll("nav a"); 2677 navLinks.forEach((link) => { 2678 link.removeAttribute("href"); 2679 link.classList.add("genealogy-muted-link"); 2680 }); 2681 root.querySelectorAll("footer a").forEach((link) => { 2682 link.removeAttribute("href"); 2683 link.classList.add("genealogy-muted-link"); 2684 }); 2685} 2686 2687function unlockHelperElement() { 2688 return root.querySelector("p.text-secondary.text-sm") || root.querySelector("p.text-secondary"); 2689} 2690 2691function setUnlockHelperMessage(message) { 2692 const helper = unlockHelperElement(); 2693 if (helper) { 2694 helper.textContent = message; 2695 } 2696} 2697 2698function resetTreeViewportState() { 2699 state.treeViewport.fitPending = false;
2700 state.treeViewport.autoFocusPending = true; 2701 state.treeViewport.restorePending = false; 2702 state.treeViewport.focusPeopleIds = []; 2703} 2704 2705function renderUnlockRecoveryView(error) { 2706 const personCount = state.data?.summary?.personCount || state.encryptedPayload?.summary?.personCount || 0; 2707 const eventCount = state.data?.summary?.eventCount || state.encryptedPayload?.summary?.eventCount || 0; 2708 const familyCount = state.data?.summary?.familyCount 2709 || state.encryptedPayload?.summary?.familyCount 2710 || state.data?.summary?.branchCount 2711 || state.encryptedPayload?.summary?.branchCount 2712 || 0; 2713 2714 root.innerHTML = ` 2715 <div class="genealogy-template-root"> 2716 <section class="mx-auto max-w-3xl px-6 py-16 sm:px-10"> 2717 <div class="rounded-[2rem] border border-outline-variant/20 bg-white/96 p-7 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.32)] sm:p-10"> 2718 <p class="text-[10px] font-black uppercase tracking-[0.3em] text-primary/80">Archive Recovery</p> 2719 <h1 class="mt-4 font-headline text-4xl font-black leading-[0.98] text-on-surface">Archive unlocked, but the family tree view could not finish rendering.</h1> 2720 <p class="mt-4 text-sm leading-7 text-on-surface-variant"> 2721 The family archive itself unlocked successfully. You can keep browsing the people directory or timeline while the tree view is recovered. 2722 </p> 2723 <div class="mt-6 flex flex-wrap gap-3 text-[11px] uppercase tracking-[0.16em] text-outline"> 2724 <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(String(personCount))} people</span> 2725 <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(String(eventCount))} events</span> 2726 <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(String(familyCount))} families</span> 2727 </div> 2728 <div class="mt-8 flex flex-wrap gap-3"> 2729 <button class="genealogy-directory-open" type="button" data-view="people">Open people directory</button> 2730 <button class="genealogy-record-link" type="button" data-view="timeline">Open timeline</button> 2731 <button class="genealogy-record-link is-secondary" type="button" data-view="tree">Retry family tree</button> 2732 </div> 2733 <p class="mt-6 text-xs leading-6 text-outline">A rendering issue was caught after decrypting the archive. Details are logged in the browser console for debugging.</p> 2734 </div> 2735 </section> 2736 </div> 2737 `; 2738 applyShellBranding(); 2739 console.error("Genealogy archive unlocked but the primary tree render failed.", error); 2740} 2741 2742function renderEnhancedTreeView() { 2743 const mobile = window.matchMedia("(max-width: 767px)").matches; 2744 const matches = state.graph.people.filter(matchesSearch).length; 2745 renderPrimaryShell({ 2746 view: "tree", 2747 eyebrow: "Lineage Visualization", 2748 titleHtml: `The <span class="italic text-primary">Family Tree</span>`, 2749 summaryText: treeSummaryText(matches), 2750 heroAsideMarkup: renderArchiveSnapshotCard(), 2751 contentMarkup: ` 2752 <section class="space-y-6"> 2753 ${renderTreeWorkspace(mobile)} 2754 </section> 2755 ` 2756 }); 2757 2758 const search = root.querySelector("input[placeholder='Search lineage...']"); 2759 if (search) { 2760 search.value = state.searchDisplay || state.searchQuery; 2761 search.dataset.role = "search"; 2762 } 2763 bindSearchInput(); 2764 2765 bindTreeInteractions(); 2766 bindTreeViewport(); 2767 if (state.treeViewport.fitPending) { 2768 fitTreeToViewport(); 2769 state.treeViewport.fitPending = false; 2770 } else if (state.treeViewport.focusPeopleIds.length) { 2771 focusTreeOnPeople(state.treeViewport.focusPeopleIds, { minScale: 0.55 }); 2772 state.treeViewport.focusPeopleIds = []; 2773 } else if (state.treeViewport.restorePending) { 2774 restoreTreeViewport(); 2775 state.treeViewport.restorePending = false; 2776 } else if (state.treeViewport.autoFocusPending) { 2777 focusTreeOnSelectedPerson(); 2778 state.treeViewport.autoFocusPending = false; 2779 } 2780 clearTreeHighlight(); 2781 updateTreeSearchUI(); 2782} 2783 2784function renderTreeView() { 2785 try { 2786 return renderEnhancedTreeView(); 2787 } catch (error) { 2788 renderUnlockRecoveryView(error); 2789 return; 2790 } 2791 2792 const mobile = window.matchMedia("(max-width: 767px)").matches; 2793 root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "treeMobile" : "treeDesktop"]}</div>`; 2794 markNav(root, "tree"); 2795 const person = selectedPerson(); 2796 const matches = state.graph.people.filter(matchesSearch).length; 2797 if (mobile) { 2798 root.querySelector("h2.noto-serif")?.replaceChildren(document.createTextNode(`${person.family || "Family"} Branch`)); 2799 root.querySelector("p.text-secondary.mt-2")?.replaceChildren(document.createTextNode(`Viewing the full family map with ${state.graph.people.length} archived people. ${matches} currently match your search.`)); 2800 const container = root.querySelector(".relative.flex.flex-col.gap-16"); 2801 if (container) { 2802 container.innerHTML = ` 2803 <div class="bg-surface-container-lowest rounded-xl p-6 shadow-sm border border-outline-variant/10"> 2804 <div class="flex items-start gap-4"> 2805 <div class="w-20 h-20 rounded-xl overflow-hidden bg-surface-container">${photoMarkup(person, "w-full h-full object-cover")}</div> 2806 <div> 2807 <h3 class="noto-serif text-2xl font-bold leading-tight">${escapeHtml(person.displayName)}</h3> 2808 <p class="text-xs uppercase tracking-widest text-secondary mt-1">${escapeHtml(formatLifeSpan(person))}</p> 2809 <p class="text-sm text-secondary mt-3">${escapeHtml(truncate(person.summary || "No biography summary recorded yet.", 140))}</p> 2810 </div> 2811 </div> 2812 </div> 2813 <div class="genealogy-mobile-board">${renderTreeBoard()}</div> 2814 `; 2815 } 2816 } else { 2817 root.querySelector("h1.font-headline")?.replaceChildren(); 2818 const hero = root.querySelector("h1.font-headline"); 2819 if (hero) { 2820 hero.innerHTML = `The <br><span class="italic text-primary">Family Tree</span>`;
2821 } 2822 const count = root.querySelector("p.text-on-surface-variant.font-medium.text-sm"); 2823 if (count) { 2824 count.textContent = `${state.graph.rows.length} generations · ${state.graph.people.length} archived people · ${matches} matches`; 2825 } 2826 const labels = root.querySelector(".absolute.-left-12"); 2827 if (labels) { 2828 labels.innerHTML = state.graph.rows.map((row) => { 2829 const first = state.graph.peopleById.get(row.ids[0]); 2830 return `<div class="rotate-180 [writing-mode:vertical-lr] font-label text-[10px] font-bold tracking-[0.3em] uppercase">Gen ${row.generation + 1} · ${escapeHtml(getBirthYear(first) || "Unknown")}</div>`; 2831 }).join(""); 2832 } 2833 const boardContainer = root.querySelector(".flex.flex-col.gap-24.items-center.overflow-x-auto.pb-12"); 2834 if (boardContainer) { 2835 boardContainer.outerHTML = renderTreeBoard(); 2836 } 2837 const statCards = root.querySelectorAll(".fixed.right-8.bottom-32 .grid.grid-cols-2 > div p:last-child"); 2838 if (statCards[0]) { 2839 statCards[0].textContent = String(state.data.summary?.openQuestionCount || 0); 2840 } 2841 if (statCards[1]) { 2842 statCards[1].textContent = String(state.data.summary?.unresolvedCount || 0); 2843 } 2844 } 2845 const search = root.querySelector("input[placeholder='Search lineage...']"); 2846 if (search) { 2847 search.value = state.searchQuery; 2848 search.dataset.role = "search"; 2849 } 2850} 2851 2852function renderEnhancedProfileViewLegacy() { 2853 state.renderedTree = null; 2854 const mobile = window.matchMedia("(max-width: 767px)").matches; 2855 root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "profileMobile" : "profileDesktop"]}</div>`; 2856 markNav(root, "profile"); 2857 const person = selectedPerson(); 2858 const personEvents = [...(state.graph.eventMap.get(person.id) || [])] 2859 .sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right))) 2860 .slice(0, 8); 2861 const sourceNotes = (person.sources || []).slice(0, 6); 2862 const attachments = (person.attachments || []).filter( 2863 (attachment) => attachment.fileUrl || attachment.previewUrl || (attachment.sourceNoteUrls || []).length 2864 ); 2865 const discrepancies = (person.discrepancies || []).slice(0, 4); 2866 const relationshipFacts = [ 2867 { field: "Parents", value: relationshipNames(person.directParents || [], state.graph.peopleById, "Not linked yet") }, 2868 { field: "Spouses / Partners", value: relationshipNames(person.directSpouses || [], state.graph.peopleById, "No partner linked") }, 2869 { field: "Children", value: relationshipNames(person.directChildren || [], state.graph.peopleById, "No children linked") }, 2870 { field: "Birthplace", value: person.life?.birth?.place || "Not yet documented" }, 2871 { field: "Death / Current place", value: person.life?.death?.place || "Not yet documented" } 2872 ]; 2873 const vitals = [ 2874 ...relationshipFacts, 2875 ...(person.vitals || []).filter((item) => item.value && !["born", "birthplace", "died", "death place"].includes(String(item.field || "").toLowerCase())) 2876 ].filter((item) => item.value).slice(0, 8); 2877 const biographyText = person.summary || sourceNotes[0] || "A life reconstructed from family memory, linked records, and current genealogy research."; 2878 2879 root.querySelectorAll("h1, h2").forEach((heading) => { 2880 if (heading.textContent.includes("Arthur") || heading.textContent.includes("Sterling") || heading.textContent.includes("Sinclair")) { 2881 heading.textContent = person.displayName; 2882 } 2883 }); 2884 2885 const subtitle = root.querySelector("p.font-label, p.text-primary.font-bold, p.font-label.text-sm"); 2886 if (subtitle) { 2887 subtitle.textContent = formatLifeSpan(person); 2888 } 2889 2890 const biography = root.querySelector(".font-headline.italic.text-2xl, .text-lg.leading-relaxed.font-headline.italic"); 2891 if (biography) { 2892 biography.textContent = truncate(biographyText, 220); 2893 biography.classList.add("genealogy-profile-quote"); 2894 } 2895 2896 const biographyBody = root.querySelector("p.text-base.text-secondary.font-manrope, p.text-base.text-secondary"); 2897 if (biographyBody) { 2898 biographyBody.textContent = truncate( 2899 sourceNotes[0] 2900 || attachments[0]?.summary 2901 || attachments[0]?.relevance 2902 || person.life?.birth?.place 2903 || "This profile is built from linked family notes, records, and evidence in the vault.", 2904 220 2905 ); 2906 } 2907 2908 root.querySelectorAll("img[data-alt]").forEach((image) => { 2909 image.outerHTML = photoMarkup(person, image.className || "w-full h-full object-cover"); 2910 }); 2911 2912 const historyStack = root.querySelector(".space-y-10, .flex.flex-col.gap-4.overflow-x-auto.pb-4.snap-x"); 2913 if (historyStack && vitals.length) { 2914 historyStack.innerHTML = vitals.map((item, index) => ` 2915 <div class="${mobile ? "min-w-[85vw] snap-center bg-surface-container-lowest p-6 rounded-xl shadow-sm border border-outline-variant/10" : "flex gap-8 group"}"> 2916 ${mobile ? "" : `<div class="text-primary font-bold pt-1">${escapeHtml(String(index + 1).padStart(2, "0"))}</div>`} 2917 <div> 2918 <h3 class="${mobile ? "text-xl font-headline mb-2" : "font-bold text-xl mb-1 group-hover:text-primary transition-colors"}">${escapeHtml(item.field)}</h3> 2919 <p class="${mobile ? "text-sm text-secondary manrope" : "text-on-surface-variant"}">${escapeHtml(item.value)}</p> 2920 ${item.source ? `<p class="text-sm mt-2 opacity-70">${escapeHtml(truncate(item.source, 140))}</p>` : ""} 2921 </div> 2922 </div> 2923 `).join(""); 2924 } 2925 2926 const triviaList = root.querySelector("ul.space-y-8"); 2927 if (triviaList) { 2928 triviaList.innerHTML = (discrepancies.length ? discrepancies : vitals.slice(0, 3)).map((item) => ` 2929 <li class="flex gap-4">
2930 <span class="material-symbols-outlined text-primary">${discrepancies.length ? "auto_awesome" : "info"}</span> 2931 <span class="text-surface-variant">${escapeHtml(truncate(item.resolution || item.value || item.field, 120))}</span> 2932 </li> 2933 `).join(""); 2934 } 2935 2936 const mediaGrid = root.querySelector(".grid.grid-cols-1.md\\:grid-cols-2.lg\\:grid-cols-4.gap-6, .grid.grid-cols-1.gap-4"); 2937 if (mediaGrid) { 2938 mediaGrid.innerHTML = attachments.map((attachment) => renderAttachmentCard(attachment, { variant: "profile", compact: true })).join("") 2939 || `<p class="text-secondary">No linked records or photos are available for this person yet.</p>`; 2940 } 2941 2942 const timeline = root.querySelector(".space-y-24, .space-y-4"); 2943 if (timeline && personEvents.length) { 2944 timeline.innerHTML = personEvents.map((event, index) => ` 2945 <div class="flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 relative"> 2946 <div class="md:w-1/2 ${index % 2 ? "md:text-left" : "md:text-right"}"> 2947 <span class="text-xs font-bold tracking-widest text-primary">${escapeHtml(eventDateLabel(event))}</span> 2948 <h4 class="font-headline text-2xl font-bold mt-2">${escapeHtml(eventTitleText(event))}</h4> 2949 <p class="text-on-surface-variant mt-2 max-w-sm ${index % 2 ? "" : "ml-auto"}">${escapeHtml(eventSummaryText(event))}</p> 2950 <p class="text-[11px] uppercase tracking-[0.18em] text-outline mt-3 ${index % 2 ? "" : "ml-auto"}">${escapeHtml(eventParticipantsLabel(event, state.graph.peopleById))}${event.placeLabel ? ` ⢠${escapeHtml(event.placeLabel)}` : ""}</p> 2951 </div> 2952 <div class="w-4 h-4 rounded-full bg-primary ring-8 ring-surface z-10 shrink-0"></div> 2953 <div class="md:w-1/2 ${index % 2 ? "flex justify-end" : ""}"> 2954 <div class="w-32 h-20 bg-surface-container-low rounded-lg flex items-center justify-center text-primary"> 2955 <span class="material-symbols-outlined">${escapeHtml(eventIcon(event.kind || event.event))}</span> 2956 </div> 2957 </div> 2958 </div> 2959 `).join(""); 2960 } 2961 2962 if (!personEvents.length && timeline) { 2963 timeline.innerHTML = ` 2964 <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/10"> 2965 <h4 class="font-headline text-2xl font-bold mb-3">Timeline still in progress</h4> 2966 <p class="text-on-surface-variant">${escapeHtml(`No named life events are directly linked to ${person.displayName} yet. Use the family tree and linked records to continue tracing this branch.`)}</p> 2967 </div> 2968 `; 2969 } 2970 2971 disablePlannedExportButtons(); 2972} 2973 2974function renderProfileView() { 2975 return renderEnhancedProfileView(); 2976 2977 const mobile = window.matchMedia("(max-width: 767px)").matches; 2978 root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "profileMobile" : "profileDesktop"]}</div>`; 2979 markNav(root, "profile"); 2980 const person = selectedPerson(); 2981 const personEvents = [...(state.graph.eventMap.get(person.id) || [])].sort((left, right) => String(left.dateSort || "").localeCompare(String(right.dateSort || ""))).slice(0, 6); 2982 const vitals = (person.vitals || []).filter((item) => item.value).slice(0, 4); 2983 const discrepancies = (person.discrepancies || []).slice(0, 3); 2984 const sourceCards = (person.sources || []).slice(0, 4); 2985 const biographyText = person.summary || person.sources?.[0] || "A life reconstructed from family memory, documents, and working genealogy research."; 2986 root.querySelectorAll("h1, h2").forEach((heading) => { 2987 if (heading.textContent.includes("Arthur") || heading.textContent.includes("Sterling") || heading.textContent.includes("Sinclair")) { 2988 heading.textContent = person.displayName; 2989 } 2990 }); 2991 const subtitle = root.querySelector("p.font-label, p.text-primary.font-bold, p.font-label.text-sm"); 2992 if (subtitle) { 2993 subtitle.textContent = formatLifeSpan(person); 2994 } 2995 const biography = root.querySelector(".font-headline.italic.text-2xl, .text-lg.leading-relaxed.font-headline.italic"); 2996 if (biography) { 2997 biography.textContent = truncate(biographyText, 220); 2998 biography.classList.add("genealogy-profile-quote"); 2999 } 3000 const biographyBody = root.querySelector("p.text-base.text-secondary.font-manrope, p.text-base.text-secondary"); 3001 if (biographyBody) { 3002 biographyBody.textContent = truncate(person.sources?.[0] || person.life?.birth?.place || "This archive entry is currently built from the best linked genealogy evidence in the vault.", 220); 3003 } 3004 root.querySelectorAll("img[data-alt]").forEach((image) => { 3005 image.outerHTML = photoMarkup(person, image.className || "w-full h-full object-cover"); 3006 }); 3007 const historyStack = root.querySelector(".space-y-10, .flex.flex-col.gap-4.overflow-x-auto.pb-4.snap-x"); 3008 if (historyStack && vitals.length) { 3009 historyStack.innerHTML = vitals.map((item, index) => ` 3010 <div class="${mobile ? "min-w-[85vw] snap-center bg-surface-container-lowest p-6 rounded-xl shadow-sm border border-outline-variant/10" : "flex gap-8 group"}"> 3011 ${mobile ? "" : `<div class="text-primary font-bold pt-1">${escapeHtml(String(index + 1).padStart(2, "0"))}</div>`} 3012 <div> 3013 <h3 class="${mobile ? "text-xl font-headline mb-2" : "font-bold text-xl mb-1 group-hover:text-primary transition-colors"}">${escapeHtml(item.field)}</h3> 3014 <p class="${mobile ? "text-sm text-secondary manrope" : "text-on-surface-variant"}">${escapeHtml(item.value)}</p> 3015 ${item.source ? `<p class="text-sm mt-2 opacity-70">${escapeHtml(truncate(item.source, 140))}</p>` : ""} 3016 </div> 3017 </div> 3018 `).join(""); 3019 } 3020 const triviaList = root.querySelector("ul.space-y-8"); 3021 if (triviaList) { 3022 triviaList.innerHTML = (discrepancies.length ? discrepancies : vitals.slice(0, 3)).map((item) => ` 3023 <li class="flex gap-4">
3024 <span class="material-symbols-outlined text-primary">${discrepancies.length ? "auto_awesome" : "info"}</span> 3025 <span class="text-surface-variant">${escapeHtml(truncate(item.resolution || item.value || item.field, 120))}</span> 3026 </li> 3027 `).join(""); 3028 } 3029 const mediaGrid = root.querySelector(".grid.grid-cols-1.md\\:grid-cols-2.lg\\:grid-cols-4.gap-6, .grid.grid-cols-1.gap-4"); 3030 if (mediaGrid) { 3031 mediaGrid.innerHTML = sourceCards.map((source, index) => ` 3032 <div class="group glass-card rounded-xl p-6 transition-all duration-300 hover:-translate-y-2 hover:shadow-xl ring-1 ring-outline-variant/10"> 3033 <span class="material-symbols-outlined text-primary mb-6 text-4xl">${index % 2 === 0 ? "description" : "link"}</span> 3034 <h5 class="font-bold text-lg mb-2 group-hover:text-primary">${escapeHtml(index === 0 ? "Primary Source" : `Source ${index + 1}`)}</h5> 3035 <p class="text-sm text-on-surface-variant">${escapeHtml(truncate(source, 120))}</p> 3036 </div> 3037 `).join("") || `<p class="text-secondary">No source cards are available for this person yet.</p>`; 3038 } 3039 const timeline = root.querySelector(".space-y-24, .space-y-4"); 3040 if (timeline && personEvents.length) { 3041 timeline.innerHTML = personEvents.map((event, index) => ` 3042 <div class="flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 relative"> 3043 <div class="md:w-1/2 ${index % 2 ? "md:text-left" : "md:text-right"}"> 3044 <span class="text-xs font-bold tracking-widest text-primary">${escapeHtml(event.dateLabel || event.dateSort || "Undated")}</span> 3045 <h4 class="font-headline text-2xl font-bold mt-2">${escapeHtml(event.title || event.category || "Family event")}</h4> 3046 <p class="text-on-surface-variant mt-2 max-w-sm ${index % 2 ? "" : "ml-auto"}">${escapeHtml(truncate(event.summary || event.description || event.title || "Recorded family event.", 140))}</p> 3047 </div> 3048 <div class="w-4 h-4 rounded-full bg-primary ring-8 ring-surface z-10 shrink-0"></div> 3049 <div class="md:w-1/2 ${index % 2 ? "flex justify-end" : ""}"> 3050 <div class="w-32 h-20 bg-surface-container-low rounded-lg flex items-center justify-center text-primary"> 3051 <span class="material-symbols-outlined">event</span> 3052 </div> 3053 </div> 3054 </div> 3055 `).join(""); 3056 } 3057 if (!personEvents.length && timeline) { 3058 timeline.innerHTML = ` 3059 <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/10"> 3060 <h4 class="font-headline text-2xl font-bold mb-3">Timeline still in progress</h4> 3061 <p class="text-on-surface-variant">${escapeHtml(`No timeline events are directly linked to ${person.displayName} yet. Use the family tree and source cards to continue tracing this branch.`)}</p> 3062 </div> 3063 `; 3064 } 3065} 3066 3067function renderEnhancedTimelineViewLegacy() { 3068 state.renderedTree = null; 3069 root.innerHTML = `<div class="genealogy-template-root">${state.templates.timelineDesktop}</div>`; 3070 markNav(root, "timeline"); 3071 const events = [...(state.data.events || [])].sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right))); 3072 const title = root.querySelector("h1.font-headline"); 3073 if (title) { 3074 title.innerHTML = `The Chronology of <br><span class="italic text-primary">Your Family.</span>`; 3075 } 3076 const intro = root.querySelector("section.max-w-5xl p.text-xl"); 3077 if (intro) { 3078 intro.textContent = `A chronological view of ${events.length} named family events, linked people, places, and supporting records across the family archive.`; 3079 } 3080 const container = root.querySelector(".space-y-32"); 3081 if (container) { 3082 container.innerHTML = events.map((event, index) => { 3083 const participants = eventParticipantsLabel(event, state.graph.peopleById); 3084 const dateLabel = eventDateLabel(event); 3085 const yearLabel = String(event?.date?.sortValue || "").slice(0, 4) || dateLabel; 3086 return ` 3087 <div class="relative flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 md:gap-0"> 3088 <div class="md:w-1/2 ${index % 2 ? "md:pl-16 text-left" : "md:pr-16 text-right"} hidden md:block"> 3089 <h3 class="text-4xl font-headline font-bold text-on-surface">${escapeHtml(eventTitleText(event))}</h3> 3090 <p class="text-primary font-bold mt-2">${escapeHtml(event.placeLabel || participants)}</p> 3091 <p class="text-xs uppercase tracking-[0.18em] text-outline mt-4">${escapeHtml(dateLabel)}</p> 3092 </div> 3093 <div class="z-10 w-4 h-4 rounded-full bg-primary ring-8 ring-white shadow-xl shadow-primary/20"></div> 3094 <div class="md:w-1/2 ${index % 2 ? "md:pr-16" : "md:pl-16"}"> 3095 <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/5"> 3096 <div class="flex items-center justify-between mb-4">
3097 <span class="text-4xl font-headline font-black text-outline-variant/20">${escapeHtml(yearLabel || "Date")}</span> 3098 <span class="material-symbols-outlined text-primary">${escapeHtml(eventIcon(event.kind || event.event))}</span> 3099 </div> 3100 <h3 class="text-2xl font-headline font-bold mb-4 md:hidden">${escapeHtml(eventTitleText(event))}</h3> 3101 <p class="text-on-surface font-body leading-relaxed">${escapeHtml(eventSummaryText(event))}</p> 3102 <div class="mt-6 flex flex-wrap gap-3 text-[11px] uppercase tracking-[0.16em] text-outline"> 3103 <span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(dateLabel)}</span> 3104 <span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(participants)}</span> 3105 ${event.placeLabel ? `<span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(event.placeLabel)}</span>` : ""} 3106 ${eventSourceLabel(event) ? `<span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(truncate(eventSourceLabel(event), 54))}</span>` : ""} 3107 </div> 3108 </div> 3109 </div> 3110 </div> 3111 `; 3112 }).join(""); 3113 } 3114 3115 disablePlannedExportButtons(); 3116} 3117 3118function renderTimelineView() { 3119 return renderEnhancedTimelineView(); 3120 3121 root.innerHTML = `<div class="genealogy-template-root">${state.templates.timelineDesktop}</div>`; 3122 markNav(root, "timeline"); 3123 const events = [...(state.data.events || [])].sort((left, right) => String(left.dateSort || "").localeCompare(String(right.dateSort || ""))).slice(0, 8); 3124 const title = root.querySelector("h1.font-headline"); 3125 if (title) { 3126 title.innerHTML = `The Chronology of <br><span class="italic text-primary">Your Family.</span>`; 3127 } 3128 const container = root.querySelector(".space-y-32"); 3129 if (container) { 3130 container.innerHTML = events.map((event, index) => ` 3131 <div class="relative flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 md:gap-0"> 3132 <div class="md:w-1/2 ${index % 2 ? "md:pl-16 text-left" : "md:pr-16 text-right"} hidden md:block"> 3133 <h3 class="text-4xl font-headline font-bold text-on-surface">${escapeHtml(event.title || event.category || "Family Event")}</h3> 3134 <p class="text-primary font-bold mt-2">${escapeHtml(event.location || "Recorded in the archive")}</p> 3135 </div> 3136 <div class="z-10 w-4 h-4 rounded-full bg-primary ring-8 ring-white shadow-xl shadow-primary/20"></div> 3137 <div class="md:w-1/2 ${index % 2 ? "md:pr-16" : "md:pl-16"}"> 3138 <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/5"> 3139 <div class="flex items-center justify-between mb-4"> 3140 <span class="text-4xl font-headline font-black text-outline-variant/20">${escapeHtml(String(event.dateSort || "").slice(0, 4) || "Date")}</span> 3141 <span class="material-symbols-outlined text-primary">history_edu</span> 3142 </div> 3143 <h3 class="text-2xl font-headline font-bold mb-4 md:hidden">${escapeHtml(event.title || event.category || "Family Event")}</h3> 3144 <p class="text-on-surface font-body leading-relaxed">${escapeHtml(truncate(event.summary || event.description || event.title || "Recorded archive event.", 170))}</p> 3145 </div> 3146 </div> 3147 </div> 3148 `).join(""); 3149 } 3150} 3151 3152function filteredPeopleForDirectory() { 3153 return [...(state.graph?.people || [])] 3154 .filter(matchesActiveFilters) 3155 .sort(personSort); 3156} 3157 3158function attachmentMatchesSearch(attachment) { 3159 const query = normalizeSearchText(state.searchQuery); 3160 if (!query) { 3161 return true; 3162 } 3163 const haystack = normalizeSearchText([ 3164 attachmentTitle(attachment), 3165 attachment?.summary || "", 3166 attachment?.relevance || "", 3167 attachment?.transcript || "", 3168 attachment?.transcriptDisclaimer || "", 3169 attachment?.transcriptSourceTitle || "", 3170 attachment?.personName || "", 3171 ...(attachment?.personNames || []), 3172 ...attachmentEvidenceFiles(attachment).map((file) => file.title), 3173 ...(attachment?.sourceNoteTitles || []) 3174 ].join(" ")); 3175 return haystack.includes(query); 3176} 3177 3178function filteredAttachmentsForDirectory() { 3179 return dedupeAttachmentRecords(collectAllAttachments() 3180 .filter((attachment) => { 3181 if (state.activeFamilyFilter && attachment.family !== state.activeFamilyFilter) { 3182 return false;
3183 } 3184 return attachmentMatchesSearch(attachment); 3185 }) 3186 .sort((left, right) => { 3187 const leftDate = left?.date?.sortValue || ""; 3188 const rightDate = right?.date?.sortValue || ""; 3189 return rightDate.localeCompare(leftDate) || String(left.personName || "").localeCompare(String(right.personName || ""), "en-GB"); 3190 })); 3191} 3192 3193function renderCollectionShellLegacy(view, titleHtml, summaryText, contentMarkup, mobileIntroMarkup = "") { 3194 const mobile = window.matchMedia("(max-width: 767px)").matches; 3195 root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "treeMobile" : "treeDesktop"]}</div>`; 3196 markNav(root, view); 3197 3198 if (mobile) { 3199 root.querySelector("h2.noto-serif")?.replaceChildren(document.createTextNode(titleHtml.replace(/<[^>]+>/g, ""))); 3200 root.querySelector("p.text-secondary.mt-2")?.replaceChildren(document.createTextNode(summaryText)); 3201 const container = root.querySelector(".relative.flex.flex-col.gap-16"); 3202 if (container) { 3203 container.innerHTML = ` 3204 ${mobileIntroMarkup} 3205 <div class="genealogy-hero-filters">${renderHeroFamilyFilters(4)}</div> 3206 ${contentMarkup} 3207 `; 3208 } 3209 } else { 3210 const headerWrap = root.querySelector("header > div.max-w-screen-2xl"); 3211 if (headerWrap) { 3212 headerWrap.classList.remove("max-w-screen-2xl", "mx-auto"); 3213 headerWrap.classList.add("max-w-none"); 3214 } 3215 const sidebar = root.querySelector("aside"); 3216 if (sidebar) { 3217 sidebar.style.width = "16rem"; 3218 } 3219 const main = root.querySelector("main"); 3220 if (main) { 3221 main.classList.remove("max-w-screen-2xl", "mx-auto", "lg:px-16"); 3222 main.classList.add("max-w-none", "w-full", "px-4", "md:px-6", "lg:px-8", "2xl:px-10"); 3223 main.style.maxWidth = "none"; 3224 main.style.width = "auto"; 3225 main.style.marginLeft = "16rem"; 3226 main.style.paddingTop = "1.75rem"; 3227 main.style.paddingBottom = "4rem"; 3228 } 3229 const heroBlock = root.querySelector("main > .mb-20"); 3230 if (heroBlock) { 3231 heroBlock.classList.remove("mb-20"); 3232 heroBlock.style.maxWidth = "none"; 3233 heroBlock.style.marginBottom = "2.5rem"; 3234 } 3235 const hero = root.querySelector("h1.font-headline"); 3236 if (hero) { 3237 hero.innerHTML = titleHtml; 3238 } 3239 const count = root.querySelector("p.text-on-surface-variant.font-medium.text-sm"); 3240 if (count) { 3241 count.textContent = summaryText; 3242 count.dataset.searchSummary = view; 3243 } 3244 const heroFilterStrip = root.querySelector(".flex.-space-x-3"); 3245 if (heroFilterStrip) { 3246 heroFilterStrip.outerHTML = `<div class="genealogy-hero-filters">${renderHeroFamilyFilters(4)}</div>`; 3247 } 3248 root.querySelector(".absolute.-left-12")?.remove(); 3249 const boardContainer = root.querySelector(".flex.flex-col.gap-24.items-center.overflow-x-auto.pb-12"); 3250 if (boardContainer) { 3251 boardContainer.outerHTML = contentMarkup; 3252 } 3253 const statsPanel = root.querySelector(".fixed.right-8.bottom-32.w-80"); 3254 if (statsPanel) { 3255 statsPanel.remove(); 3256 } 3257 } 3258 3259 const search = root.querySelector("input[placeholder='Search lineage...']"); 3260 if (search) { 3261 search.value = state.searchDisplay || state.searchQuery; 3262 search.dataset.role = "search"; 3263 } 3264 bindSearchInput(); 3265 disablePlannedExportButtons(); 3266} 3267 3268function renderPeopleView() { 3269 state.renderedTree = null; 3270 const people = filteredPeopleForDirectory(); 3271 const contentMarkup = ` 3272 <section class="genealogy-directory-grid"> 3273 ${people.map((person) => ` 3274 <article class="genealogy-directory-card" style="--family-color:${escapeHtml(person.familyColor || "#0041c8")}"> 3275 <div class="genealogy-directory-card-top"> 3276 <div class="genealogy-directory-photo">${photoMarkup(person, "w-full h-full object-cover")}</div> 3277 <span class="genealogy-family-chip">${escapeHtml(familyLabel(person))}</span> 3278 </div> 3279 <h3>${escapeHtml(person.displayName)}</h3> 3280 <p class="genealogy-directory-life">${escapeHtml(formatLifeSpan(person))}</p> 3281 <p class="genealogy-directory-summary">${escapeHtml(truncate(person.summary || "No narrative summary recorded yet.", 160))}</p> 3282 <div class="genealogy-directory-facts">
3283 <span>${escapeHtml(relationshipNames(person.directParents || [], state.graph.peopleById, "Parents not linked"))}</span> 3284 <span>${escapeHtml(pluralize(personRecordCount(person), "record"))}</span> 3285 </div> 3286 <button class="genealogy-directory-open" type="button" data-person-id="${escapeHtml(person.id)}" data-open-profile="true">Open profile</button> 3287 </article> 3288 `).join("")} 3289 </section> 3290 `; 3291 const mobileIntro = ` 3292 <div class="bg-surface-container-lowest rounded-xl p-6 shadow-sm border border-outline-variant/10"> 3293 <h3 class="noto-serif text-2xl font-bold leading-tight">People in the family archive</h3> 3294 <p class="text-sm text-secondary mt-3">${escapeHtml(`Browse ${people.length} people, their linked records, and their place in the family story.`)}</p> 3295 </div> 3296 `; 3297 renderCollectionShell( 3298 "people", 3299 `The People in <br><span class="italic text-primary">Your Family.</span>`, 3300 `${people.length} people currently match your family and search filters.`, 3301 contentMarkup, 3302 mobileIntro 3303 ); 3304} 3305
3306function renderSourcesView() { 3307 state.renderedTree = null; 3308 const attachments = filteredAttachmentsForDirectory(); 3309 const contentMarkup = ` 3310 <section class="genealogy-sources-grid"> 3311 ${attachments.map((attachment) => renderAttachmentCard(attachment, { variant: "source" })).join("") || `<div class="genealogy-empty-state">No linked record previews match the current filters yet.</div>`} 3312 </section> 3313 `; 3314 const mobileIntro = ` 3315 <div class="bg-surface-container-lowest rounded-xl p-6 shadow-sm border border-outline-variant/10"> 3316 <h3 class="noto-serif text-2xl font-bold leading-tight">Sources and records</h3> 3317 <p class="text-sm text-secondary mt-3">${escapeHtml(`Browse linked wills, certificates, notes, and document previews across the family archive.`)}</p> 3318 </div> 3319 `; 3320 renderCollectionShell( 3321 "sources", 3322 `The Sources Behind <br><span class="italic text-primary">Your Family.</span>`, 3323 `${attachments.length} linked records currently match your family and search filters.`, 3324 contentMarkup, 3325 mobileIntro 3326 ); 3327} 3328 3329function renderResearchViewLegacy() { 3330 state.renderedTree = null; 3331 root.innerHTML = `<div class="genealogy-template-root">${state.templates.researchDesktop}</div>`; 3332 markNav(root, "research"); 3333 const speculativePeople = state.graph.people.filter((person) => person.status === "candidate" || person.status === "unresolved").slice(0, 8); 3334 const lead = speculativePeople[0] || selectedPerson(); 3335 root.querySelector("h1.font-headline")?.replaceChildren(document.createTextNode("Unverified & Potential Connections")); 3336 const name = root.querySelector("h3.text-2xl.font-headline.font-bold"); 3337 if (name) { 3338 name.textContent = lead.displayName; 3339 } 3340 const match = root.querySelector("span.px-3.py-1"); 3341 if (match) { 3342 match.textContent = `${lead.confidence === "high" ? 92 : lead.confidence === "medium" ? 68 : 41}% Match`; 3343 } 3344 const cards = root.querySelector(".grid.grid-cols-1.sm\\:grid-cols-2.lg\\:grid-cols-4.gap-6"); 3345 if (cards) { 3346 cards.innerHTML = speculativePeople.map((person) => ` 3347 <div class="bg-surface-container-lowest p-6 rounded-xl hover:-translate-y-2 transition-transform duration-300 border-b-4 border-transparent hover:border-primary"> 3348 <div class="flex justify-between items-start mb-4"> 3349 <div class="w-10 h-10 bg-zinc-100 rounded-full flex items-center justify-center text-zinc-400">${photoMarkup(person, "w-full h-full object-cover rounded-full")}</div> 3350 <span class="text-[10px] font-bold text-zinc-400">${escapeHtml(person.confidence || "low")} confidence</span> 3351 </div> 3352 <h5 class="font-headline font-bold text-on-background">${escapeHtml(person.displayName)}</h5> 3353 <p class="text-[10px] text-zinc-500 mb-4 uppercase tracking-tighter">${escapeHtml(formatLifeSpan(person))}</p> 3354 <div class="flex gap-2"> 3355 <button class="text-xs font-bold text-primary px-3 py-1 bg-primary/5 rounded" data-person-id="${escapeHtml(person.id)}" data-open-profile="true">Details</button> 3356 </div> 3357 </div> 3358 `).join(""); 3359 } 3360 3361 root.querySelectorAll("h4").forEach((heading) => { 3362 const text = String(heading.textContent || "").trim(); 3363 if (text === "Auto-Discovery") { 3364 const panel = heading.closest("div.bg-primary"); 3365 if (panel) { 3366 panel.classList.add("genealogy-disabled-panel"); 3367 panel.setAttribute("aria-disabled", "true"); 3368 } 3369 heading.textContent = "Auto-Discovery Unavailable"; 3370 } 3371 }); 3372 3373 root.querySelectorAll("button").forEach((button) => { 3374 const text = String(button.textContent || "").replace(/\s+/g, " ").trim(); 3375 if (/Launch Deep Scanner/i.test(text)) { 3376 button.classList.add("genealogy-disabled-button"); 3377 button.disabled = true; 3378 button.setAttribute("aria-disabled", "true"); 3379 button.title = "This scanner is not active yet."; 3380 const tooltip = button.querySelector("div"); 3381 if (tooltip) { 3382 tooltip.textContent = "Scanner unavailable"; 3383 } 3384 } 3385 }); 3386 3387 root.querySelectorAll("p").forEach((paragraph) => { 3388 const text = String(paragraph.textContent || "").trim(); 3389 if (text.includes("archivist is scanning")) { 3390 paragraph.textContent = "Automatic archive scanning is not active in this view yet."; 3391 } 3392 if (text.includes("% Complete")) { 3393 paragraph.textContent = "Inactive"; 3394 } 3395 }); 3396} 3397 3398function renderLegacy() { 3399 if (!state.graph) { 3400 renderLockedView(); 3401 applyShellBranding(); 3402 return; 3403 } 3404 if (state.activeView === "profile") { 3405 renderProfileView(); 3406 applyShellBranding(); 3407 return; 3408 } 3409 if (state.activeView === "timeline") { 3410 renderTimelineView(); 3411 applyShellBranding(); 3412 return; 3413 } 3414 if (state.activeView === "people") { 3415 renderPeopleView(); 3416 applyShellBranding(); 3417 return; 3418 } 3419 if (state.activeView === "sources") { 3420 renderSourcesView(); 3421 applyShellBranding(); 3422 return; 3423 } 3424 renderTreeView(); 3425 applyShellBranding(); 3426} 3427 3428function renderEnhancedProfileView() { 3429 state.renderedTree = null; 3430 const person = selectedPerson(); 3431 const personEvents = [...(state.graph.eventMap.get(person.id) || [])] 3432 .sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right))) 3433 .slice(0, 8); 3434 const sourceNotes = (person.sources || []).slice(0, 6); 3435 const attachments = (person.attachments || []).filter( 3436 (attachment) => attachment.fileUrl || attachment.previewUrl || (attachment.sourceNoteUrls || []).length 3437 ); 3438 const discrepancies = (person.discrepancies || []).slice(0, 4); 3439 const relationshipFacts = [ 3440 { field: "Parents", value: relationshipNames(person.directParents || [], state.graph.peopleById, "Not linked yet") }, 3441 { field: "Spouses / Partners", value: relationshipNames(person.directSpouses || [], state.graph.peopleById, "No partner linked") }, 3442 { field: "Children", value: relationshipNames(person.directChildren || [], state.graph.peopleById, "No children linked") }, 3443 { field: "Birthplace", value: person.life?.birth?.place || "Not yet documented" }, 3444 { field: "Death / Current place", value: person.life?.death?.place || "Not yet documented" } 3445 ]; 3446 const vitals = [ 3447 ...relationshipFacts, 3448 ...(person.vitals || []).filter((item) => item.value && !["born", "birthplace", "died", "death place"].includes(String(item.field || "").toLowerCase())) 3449 ].filter((item) => item.value).slice(0, 8); 3450 const biographyText = person.summary || sourceNotes[0] || "A life reconstructed from family memory, linked records, and current genealogy research."; 3451 const summary = state.graph.summaryById.get(person.id) || { parents: [], children: [], spouses: [] }; 3452 const attachmentMarkup = attachments.length 3453 ? attachments.map((attachment) => renderAttachmentCard(attachment, { variant: "profile", compact: true })).join("") 3454 : `<div class="genealogy-empty-state">No linked records or photos are available for this person yet.</div>`; 3455 const sourceMarkup = sourceNotes.length 3456 ? `<ul class="space-y-3">${sourceNotes.map((note) => `<li class="genealogy-profile-prose rounded-2xl bg-surface-container-low px-4 py-3 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(note, 220))}</li>`).join("")}</ul>` 3457 : `<div class="genealogy-empty-state">No source notes are linked to this person yet.</div>`; 3458 const discrepancyMarkup = discrepancies.length 3459 ? ` 3460 <section class="rounded-[1.5rem] border border-outline-variant/20 bg-surface-container-low p-5"> 3461 <h3 class="text-lg font-bold text-on-surface">Research notes and discrepancies</h3> 3462 <ul class="mt-4 space-y-3"> 3463 ${discrepancies.map((item) => ` 3464 <li class="flex gap-3 text-sm leading-6 text-on-surface-variant">
3465 <span class="material-symbols-outlined text-primary">rule</span> 3466 <span>${escapeHtml(item.resolution || item.value || item.field)}</span> 3467 </li> 3468 `).join("")} 3469 </ul> 3470 </section> 3471 ` 3472 : ""; 3473 const eventMarkup = personEvents.length 3474 ? personEvents.map((event) => ` 3475 <article class="rounded-[1.5rem] border border-outline-variant/20 bg-white/96 p-5 shadow-[0_18px_42px_-34px_rgba(27,27,27,0.32)]"> 3476 <div class="flex flex-wrap items-center justify-between gap-3"> 3477 <div> 3478 <p class="text-[11px] font-black uppercase tracking-[0.18em] text-primary/80">${escapeHtml(eventDateLabel(event))}</p> 3479 <h3 class="mt-2 font-headline text-2xl font-bold text-on-surface">${escapeHtml(eventTitleText(event))}</h3> 3480 </div> 3481 <span class="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary"> 3482 <span class="material-symbols-outlined">${escapeHtml(eventIcon(event.kind || event.event))}</span> 3483 </span> 3484 </div> 3485 <p class="mt-4 text-sm leading-6 text-on-surface-variant">${escapeHtml(eventSummaryText(event))}</p> 3486 <p class="mt-4 text-[11px] uppercase tracking-[0.16em] text-outline">${escapeHtml(eventParticipantsLabel(event, state.graph.peopleById))}${event.placeLabel ? ` ⢠${escapeHtml(event.placeLabel)}` : ""}</p> 3487 </article> 3488 `).join("") 3489 : `<div class="genealogy-empty-state">No named life events are directly linked to ${escapeHtml(person.displayName)} yet.</div>`; 3490 3491 renderPrimaryShell({ 3492 view: "profile", 3493 eyebrow: `${familyLabel(person)} Profile`, 3494 titleHtml: escapeHtml(person.displayName), 3495 summaryText: `${formatLifeSpan(person)} | ${pluralize(personEvents.length, "named event")} | ${pluralize(attachments.length, "linked record")}`, 3496 heroAsideMarkup: ` 3497 <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]"> 3498 <div class="flex items-start gap-4"> 3499 <div class="h-24 w-24 overflow-hidden rounded-[1.4rem] bg-surface-container-low">${photoMarkup(person, "h-full w-full object-cover")}</div> 3500 <div class="min-w-0 flex-1"> 3501 <span class="genealogy-family-chip">${escapeHtml(familyLabel(person))}</span> 3502 <p class="genealogy-profile-prose mt-3 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(biographyText, 180))}</p> 3503 </div> 3504 </div> 3505 </section> 3506 `, 3507 contentMarkup: ` 3508 <section class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]"> 3509 <article class="min-w-0 rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.28)] md:p-8"> 3510 <div class="flex flex-col gap-6 sm:flex-row sm:items-start"> 3511 <div class="h-36 w-36 overflow-hidden rounded-[1.6rem] bg-surface-container-low shadow-sm">${photoMarkup(person, "h-full w-full object-cover")}</div> 3512 <div class="min-w-0 flex-1"> 3513 <div class="flex flex-wrap gap-2">${renderPersonKeyFacts(person, summary)}</div> 3514 <p class="genealogy-profile-prose mt-4 text-lg leading-8 text-on-surface">${escapeHtml(truncate(biographyText, 260))}</p> 3515 <p class="genealogy-profile-prose mt-4 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(sourceNotes[0] || attachments[0]?.relevance || "This profile is built from linked family notes, records, and evidence in the vault.", 240))}</p> 3516 </div> 3517 </div> 3518 <div class="mt-8 grid gap-3 md:grid-cols-2"> 3519 ${vitals.map((item) => ` 3520 <div class="rounded-2xl border border-outline-variant/15 bg-surface-container-low p-4"> 3521 <p class="text-[10px] font-black uppercase tracking-[0.18em] text-outline">${escapeHtml(item.field)}</p> 3522 <p class="mt-2 text-sm leading-6 text-on-surface">${escapeHtml(item.value)}</p> 3523 </div> 3524 `).join("") || `<div class="genealogy-empty-state md:col-span-2">No structured life facts are linked to this person yet.</div>`} 3525 </div> 3526 ${discrepancyMarkup} 3527 </article> 3528 <div class="min-w-0 space-y-6"> 3529 <section class="min-w-0 rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)]"> 3530 <div class="flex items-center justify-between gap-3"> 3531 <div> 3532 <p class="text-[10px] font-black uppercase tracking-[0.2em] text-primary/80">Evidence Gallery</p> 3533 <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">Photos, records, and documents</h2> 3534 </div>
3535 <span class="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold uppercase tracking-[0.16em] text-primary">${escapeHtml(pluralize(attachments.length, "record"))}</span> 3536 </div> 3537 <div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">${attachmentMarkup}</div> 3538 </section> 3539 <section class="min-w-0 rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)]"> 3540 <p class="text-[10px] font-black uppercase tracking-[0.2em] text-primary/80">Notes and Source Context</p> 3541 <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">Linked source notes</h2> 3542 <div class="mt-6">${sourceMarkup}</div> 3543 </section> 3544 </div> 3545 </section> 3546 <section class="rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)] md:p-8"> 3547 <div class="flex flex-wrap items-end justify-between gap-4"> 3548 <div> 3549 <p class="text-[10px] font-black uppercase tracking-[0.2em] text-primary/80">Person Timeline</p> 3550 <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">Named events tied to ${escapeHtml(person.displayName)}</h2> 3551 </div> 3552 <span class="rounded-full bg-surface-container-low px-3 py-1 text-xs font-bold uppercase tracking-[0.16em] text-outline">${escapeHtml(pluralize(personEvents.length, "event"))}</span> 3553 </div> 3554 <div class="mt-6 space-y-4">${eventMarkup}</div> 3555 </section> 3556 ` 3557 }); 3558 3559 disablePlannedExportButtons(); 3560} 3561 3562function renderEnhancedTimelineView() { 3563 state.renderedTree = null; 3564 const events = [...(state.data.events || [])].sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right))); 3565 const eventMarkup = events.map((event) => { 3566 const participants = eventParticipantsLabel(event, state.graph.peopleById); 3567 const dateLabel = eventDateLabel(event); 3568 const yearLabel = String(event?.date?.sortValue || "").slice(0, 4) || dateLabel; 3569 return ` 3570 <article class="rounded-[1.7rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_22px_56px_-38px_rgba(27,27,27,0.28)] md:p-8"> 3571 <div class="flex flex-wrap items-start justify-between gap-4"> 3572 <div> 3573 <p class="text-[11px] font-black uppercase tracking-[0.18em] text-primary/80">${escapeHtml(dateLabel)}</p> 3574 <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">${escapeHtml(eventTitleText(event))}</h2> 3575 <p class="mt-3 text-sm font-semibold uppercase tracking-[0.16em] text-outline">${escapeHtml(participants)}${event.placeLabel ? ` ⢠${escapeHtml(event.placeLabel)}` : ""}</p> 3576 </div> 3577 <div class="flex items-center gap-3"> 3578 <span class="text-4xl font-headline font-black text-outline-variant/40">${escapeHtml(yearLabel)}</span> 3579 <span class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary"> 3580 <span class="material-symbols-outlined">${escapeHtml(eventIcon(event.kind || event.event))}</span> 3581 </span> 3582 </div> 3583 </div> 3584 <p class="mt-5 max-w-4xl text-sm leading-7 text-on-surface-variant">${escapeHtml(eventSummaryText(event))}</p> 3585 <div class="mt-5 flex flex-wrap gap-3 text-[11px] uppercase tracking-[0.16em] text-outline"> 3586 <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(dateLabel)}</span> 3587 <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(participants)}</span> 3588 ${event.placeLabel ? `<span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(event.placeLabel)}</span>` : ""} 3589 ${eventSourceLabel(event) ? `<span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(truncate(eventSourceLabel(event), 54))}</span>` : ""} 3590 </div> 3591 </article> 3592 `; 3593 }).join(""); 3594 3595 renderPrimaryShell({ 3596 view: "timeline", 3597 eyebrow: "Chronology", 3598 titleHtml: `The Chronology of <span class="italic text-primary">Your Family</span>`, 3599 summaryText: `A chronological view of ${events.length}
3599 named family events, linked people, places, and supporting records across the archive.`, 3600 heroAsideMarkup: ` 3601 <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]"> 3602 <p class="text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">Timeline Overview</p> 3603 <p class="mt-3 text-sm leading-6 text-on-surface-variant">Every event is named with the people involved, the place where known, and the source context that supports it.</p> 3604 </section> 3605 `, 3606 contentMarkup: ` 3607 <section class="space-y-5"> 3608 ${eventMarkup || `<div class="genealogy-empty-state">No chronology events have been linked yet.</div>`} 3609 </section> 3610 ` 3611 }); 3612 3613 disablePlannedExportButtons(); 3614} 3615 3616function renderCollectionShell(view, titleHtml, summaryText, contentMarkup, mobileIntroMarkup = "") { 3617 const eyebrow = view === "people" 3618 ? "People Directory" 3619 : view === "sources" 3620 ? "Sources and Records" 3621 : "Family Archive"; 3622 renderPrimaryShell({ 3623 view, 3624 eyebrow, 3625 titleHtml, 3626 summaryText, 3627 heroAsideMarkup: ` 3628 <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]"> 3629 <p class="text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">${escapeHtml(eyebrow)}</p> 3630 <p class="mt-3 text-sm leading-6 text-on-surface-variant">${escapeHtml(view === "people" 3631 ? "Browse the people in the archive, then open a profile to see their records, timeline, and linked relatives." 3632 : "Open wills, certificates, images, and linked document previews from across the family archive.")}</p> 3633 </section> 3634 `, 3635 contentMarkup: ` 3636 <section class="space-y-6"> 3637 ${mobileIntroMarkup || ""} 3638 ${contentMarkup} 3639 </section> 3640 ` 3641 }); 3642 3643 const search = root.querySelector("input[placeholder='Search lineage...']"); 3644 if (search) { 3645 search.value = state.searchDisplay || state.searchQuery; 3646 search.dataset.role = "search"; 3647 } 3648 bindSearchInput(); 3649 disablePlannedExportButtons(); 3650} 3651 3652function renderResearchView() { 3653 state.renderedTree = null; 3654 const speculativePeople = state.graph.people.filter((person) => person.status === "candidate" || person.status === "unresolved").slice(0, 8); 3655 renderPrimaryShell({ 3656 view: "research", 3657 eyebrow: "Research Admin", 3658 titleHtml: "Research tools stay outside the family-facing archive.", 3659 summaryText: "Speculative review, exports, and deeper admin tooling are intentionally hidden from everyday family browsing.", 3660 includeFilters: false, 3661 heroAsideMarkup: ` 3662 <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]"> 3663 <p class="text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">Admin Only</p> 3664 <p class="mt-3 text-sm leading-6 text-on-surface-variant">This route is kept for maintenance and review only. The family-facing navigation stays focused on the tree, timeline, people, and sources.</p> 3665 </section> 3666 `, 3667 contentMarkup: ` 3668 <section class="rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)] md:p-8"> 3669 <h2 class="font-headline text-3xl font-bold text-on-surface">Speculative links held for review</h2> 3670 <p class="mt-3 max-w-3xl text-sm leading-6 text-on-surface-variant">These people remain visible in the data, but the research controls themselves are intentionally kept out of the normal family flow.</p> 3671 <div class="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> 3672 ${speculativePeople.map((person) => ` 3673 <article class="rounded-[1.4rem] border border-outline-variant/20 bg-surface-container-low p-5"> 3674 <div class="flex items-start justify-between gap-3"> 3675 <div class="h-11 w-11 overflow-hidden rounded-2xl bg-white">${photoMarkup(person, "h-full w-full object-cover")}</div> 3676 <span class="rounded-full bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-outline">${escapeHtml(person.confidence || "low")} confidence</span> 3677 </div> 3678 <h3 class="mt-4 font-headline text-xl font-bold text-on-surface">${escapeHtml(person.displayName)}</h3> 3679 <p class="mt-2 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(person.summary || "Candidate relationship held for review.", 140))}</p> 3680 </article> 3681 `).join("") || `<div class="genealogy-empty-state">No speculative people are currently flagged.</div>`} 3682 </div> 3683 </section> 3684 ` 3685 }); 3686} 3687 3688function render() { 3689 if (!state.graph) { 3690 renderLockedView(); 3691 applyShellBranding(); 3692 return; 3693 } 3694 if (state.activeView === "profile") { 3695 renderProfileView(); 3696 return; 3697 } 3698 if (state.activeView === "timeline") { 3699 renderTimelineView(); 3700 return; 3701 } 3702 if (state.activeView === "people") { 3703 renderPeopleView(); 3704 return; 3705 } 3706 if (state.activeView === "sources") { 3707 renderSourcesView(); 3708 return; 3709 } 3710 renderTreeView(); 3711} 3712 3713async function unlockArchive(password) { 3714 const decrypted = await decryptPayload(state.encryptedPayload, password); 3715 state.data = decrypted; 3716 try { 3717 state.graph = buildGraph(decrypted); 3718 state.selectedPersonId = decrypted.meta.focusPersonId || state.graph.people[0]?.id || ""; 3719 state.activeView = "tree"; 3720 resetTreeViewportState(); 3721 render(); 3722 } catch (error) { 3723 renderUnlockRecoveryView(error); 3724 } 3725} 3726 3727root.addEventListener("submit", async (event) => { 3728 const form = event.target.closest("[data-role='unlock-form']"); 3729 if (!form) { 3730 return; 3731 } 3732 event.preventDefault(); 3733 const password = root.querySelector("#archive-password")?.value ?? ""; 3734 setUnlockHelperMessage("Unlocking the family archive..."); 3735 try { 3736 await unlockArchive(password); 3737 } catch (error) { 3738 console.error(error); 3739 setUnlockHelperMessage("That password did not unlock the archive."); 3740 } 3741}); 3742 3743root.addEventListener("input", (event) => { 3744 if (event.target.dataset.role === "search") { 3745 state.searchDisplay = String(event.target.value || ""); 3746 state.searchQuery = normalizeSearchText(event.target.value || ""); 3747 if (!updateTreeSearchUI()) { 3748 render(); 3749 } 3750 } 3751}); 3752 3753root.addEventListener("click", (event) => { 3754 const viewTarget = event.target.closest("[data-view]"); 3755 if (viewTarget && state.graph) { 3756 event.preventDefault(); 3757 state.activeView = viewTarget.dataset.view; 3758 state.treeViewport.autoFocusPending = viewTarget.dataset.view === "tree"; 3759 state.treeViewport.fitPending = false;
3760 state.treeViewport.focusPeopleIds = []; 3761 render(); 3762 return; 3763 } 3764 const toggle = event.target.closest("[data-action='toggle-password']"); 3765 if (toggle) { 3766 state.passwordVisible = !state.passwordVisible; 3767 const input = root.querySelector("#archive-password"); 3768 if (input) { 3769 input.type = state.passwordVisible ? "text" : "password"; 3770 } 3771 return; 3772 } 3773 const sidebarToggle = event.target.closest("[data-action='toggle-family-sidebar']"); 3774 if (sidebarToggle && state.graph) { 3775 state.familySidebarCollapsed = !state.familySidebarCollapsed; 3776 render(); 3777 return; 3778 } 3779 const suggestionTarget = event.target.closest("[data-search-suggestion]"); 3780 if (suggestionTarget && state.graph) { 3781 applySearchSuggestion(suggestionTarget.dataset.searchSuggestion); 3782 return; 3783 } 3784 const familyFilterTarget = event.target.closest("[data-family-filter]"); 3785 if (familyFilterTarget && state.graph) { 3786 const family = familyFilterTarget.dataset.familyFilter; 3787 state.activeFamilyFilter = state.activeFamilyFilter === family ? "" : family; 3788 state.lockedTreeHighlight = state.activeFamilyFilter ? familyHighlightPayload(state.activeFamilyFilter) : null; 3789 const selectedFamilyModel = state.activeFamilyFilter ? buildSelectedFamilyModel() : null; 3790 const focusIds = selectedFamilyModel?.relatedIds || state.lockedTreeHighlight?.peopleIds || []; 3791 const representative = state.activeFamilyFilter ? representativeForFamily(state.activeFamilyFilter) : null; 3792 state.familySidebarCollapsed = false; 3793 if (representative) { 3794 state.selectedPersonId = representative.id; 3795 } 3796 if (focusIds.length) { 3797 queueTreeFocus(focusIds); 3798 } else { 3799 rememberTreeViewport(); 3800 state.treeViewport.restorePending = true; 3801 state.treeViewport.autoFocusPending = false; 3802 state.treeViewport.focusPeopleIds = []; 3803 } 3804 render(); 3805 return; 3806 } 3807 const profileTarget = event.target.closest("[data-open-profile='true']"); 3808 if (profileTarget && state.graph) { 3809 state.selectedPersonId = profileTarget.dataset.personId; 3810 state.activeView = "profile"; 3811 state.treeViewport.focusPeopleIds = []; 3812 render(); 3813 return; 3814 } 3815 const personTarget = event.target.closest("[data-person-id]"); 3816 if (personTarget && state.graph) { 3817 state.selectedPersonId = personTarget.dataset.personId; 3818 state.treeViewport.focusPeopleIds = []; 3819 render(); 3820 } 3821}); 3822 3823window.addEventListener("resize", () => { 3824 if (state.graph) { 3825 state.graph = buildGraph(state.data); 3826 state.treeViewport.fitPending = false; 3827 state.treeViewport.restorePending = false; 3828 state.treeViewport.autoFocusPending = state.activeView === "tree"; 3829 state.treeViewport.focusPeopleIds = []; 3830 render(); 3831 } 3832}); 3833 3834async function init() { 3835 const [payloadResponse] = await Promise.all([ 3836 fetch(`${DATA_URL}?ts=${Date.now()}`, { cache: "no-store" }), 3837 loadTemplates() 3838 ]); 3839 state.encryptedPayload = await payloadResponse.json(); 3840 render(); 3841} 3842 3843init().catch((error) => { 3844 console.error(error); 3845 root.innerHTML = `<div class="min-h-screen flex items-center justify-center p-8"><div class="bg-white rounded-xl shadow-xl p-8 max-w-lg w-full"><h1 class="font-headline text-3xl font-bold mb-3">Genealogy archive unavailable</h1><p class="text-secondary">The Stitch-powered genealogy interface could not be loaded.</p></div></div>`; 3846});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.