interactive intro to open social

feat: add MST visualization with interactive hover and click

- add tabbed interface for collections with 5+ records (records/mst tabs)
- implement canvas-based MST tree visualization
- add hover tooltips showing TID for each node
- add click modal showing full record details (TID, CID, URI, JSON data)
- improve tree layout algorithm to prevent node overlap
- hide scrollbar in detail panel and increase width to 500px
- link to atproto docs for MST and TID explanations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+630 -15
src
static
+216 -1
src/templates.rs
··· 532 532 top: 0; 533 533 left: 0; 534 534 bottom: 0; 535 - width: 320px; 535 + width: 500px; 536 536 background: var(--surface); 537 537 border-right: 2px solid var(--border); 538 538 padding: 2.5rem 2rem; ··· 541 541 transform: translateX(-100%); 542 542 transition: all 0.25s ease; 543 543 z-index: 1000; 544 + scrollbar-width: none; 545 + -ms-overflow-style: none; 546 + }} 547 + 548 + .detail-panel::-webkit-scrollbar {{ 549 + display: none; 544 550 }} 545 551 546 552 .detail-panel.visible {{ ··· 644 650 .tree-item-count {{ 645 651 font-size: 0.65rem; 646 652 color: var(--text-light); 653 + }} 654 + 655 + .collection-content {{ 656 + margin-top: 0.5rem; 657 + padding-top: 0.5rem; 658 + border-top: 1px solid var(--border); 659 + }} 660 + 661 + .collection-tabs {{ 662 + display: flex; 663 + gap: 0; 664 + margin-bottom: 0.75rem; 665 + border: 1px solid var(--border); 666 + border-radius: 2px; 667 + overflow: hidden; 668 + }} 669 + 670 + .collection-tab {{ 671 + flex: 1; 672 + padding: 0.5rem 0.75rem; 673 + background: var(--bg); 674 + border: none; 675 + border-right: 1px solid var(--border); 676 + color: var(--text-light); 677 + font-family: inherit; 678 + font-size: 0.65rem; 679 + cursor: pointer; 680 + transition: all 0.15s ease; 681 + -webkit-tap-highlight-color: transparent; 682 + }} 683 + 684 + .collection-tab:last-child {{ 685 + border-right: none; 686 + }} 687 + 688 + .collection-tab:hover {{ 689 + background: var(--surface); 690 + color: var(--text); 691 + }} 692 + 693 + .collection-tab.active {{ 694 + background: var(--surface-hover); 695 + color: var(--text); 696 + font-weight: 500; 697 + }} 698 + 699 + .collection-view-content {{ 700 + position: relative; 701 + }} 702 + 703 + .collection-view {{ 704 + display: none; 705 + }} 706 + 707 + .collection-view.active {{ 708 + display: block; 709 + }} 710 + 711 + .structure-view {{ 712 + min-height: 600px; 713 + }} 714 + 715 + .mst-canvas {{ 716 + width: 100%; 717 + height: 600px; 718 + border: 1px solid var(--border); 719 + border-radius: 4px; 720 + background: var(--bg); 721 + margin-top: 0.5rem; 722 + }} 723 + 724 + .mst-info {{ 725 + background: var(--bg); 726 + border: 1px solid var(--border); 727 + padding: 0.75rem; 728 + border-radius: 4px; 729 + margin-bottom: 0.75rem; 730 + }} 731 + 732 + .mst-info p {{ 733 + font-size: 0.65rem; 734 + color: var(--text-lighter); 735 + line-height: 1.5; 736 + margin: 0; 737 + }} 738 + 739 + .mst-node-modal {{ 740 + position: fixed; 741 + inset: 0; 742 + background: rgba(0, 0, 0, 0.75); 743 + display: flex; 744 + align-items: center; 745 + justify-content: center; 746 + z-index: 3000; 747 + padding: 1rem; 748 + }} 749 + 750 + .mst-node-modal-content {{ 751 + background: var(--surface); 752 + border: 2px solid var(--border); 753 + padding: 2rem; 754 + border-radius: 4px; 755 + max-width: 600px; 756 + width: 100%; 757 + max-height: 80vh; 758 + overflow-y: auto; 759 + position: relative; 760 + }} 761 + 762 + .mst-node-close {{ 763 + position: absolute; 764 + top: 1rem; 765 + right: 1rem; 766 + width: 32px; 767 + height: 32px; 768 + border: 1px solid var(--border); 769 + background: var(--bg); 770 + color: var(--text-light); 771 + cursor: pointer; 772 + display: flex; 773 + align-items: center; 774 + justify-content: center; 775 + font-size: 1.2rem; 776 + line-height: 1; 777 + transition: all 0.2s ease; 778 + border-radius: 2px; 779 + }} 780 + 781 + .mst-node-close:hover {{ 782 + background: var(--surface-hover); 783 + border-color: var(--text-light); 784 + color: var(--text); 785 + }} 786 + 787 + .mst-node-modal-content h3 {{ 788 + margin-bottom: 1rem; 789 + font-size: 0.9rem; 790 + color: var(--text); 791 + }} 792 + 793 + .mst-node-info {{ 794 + background: var(--bg); 795 + border: 1px solid var(--border); 796 + padding: 0.75rem; 797 + border-radius: 4px; 798 + margin-bottom: 1rem; 799 + }} 800 + 801 + .mst-node-field {{ 802 + display: flex; 803 + gap: 0.5rem; 804 + margin-bottom: 0.5rem; 805 + font-size: 0.65rem; 806 + }} 807 + 808 + .mst-node-field:last-child {{ 809 + margin-bottom: 0; 810 + }} 811 + 812 + .mst-node-label {{ 813 + color: var(--text-light); 814 + font-weight: 500; 815 + min-width: 40px; 816 + }} 817 + 818 + .mst-node-value {{ 819 + color: var(--text); 820 + word-break: break-all; 821 + font-family: monospace; 822 + }} 823 + 824 + .mst-node-explanation {{ 825 + background: var(--bg); 826 + border: 1px solid var(--border); 827 + padding: 0.75rem; 828 + border-radius: 4px; 829 + margin-bottom: 1rem; 830 + }} 831 + 832 + .mst-node-explanation p {{ 833 + font-size: 0.65rem; 834 + color: var(--text-lighter); 835 + line-height: 1.5; 836 + margin: 0; 837 + }} 838 + 839 + .mst-node-data {{ 840 + background: var(--bg); 841 + border: 1px solid var(--border); 842 + border-radius: 4px; 843 + overflow: hidden; 844 + }} 845 + 846 + .mst-node-data-header {{ 847 + font-size: 0.65rem; 848 + color: var(--text-light); 849 + padding: 0.5rem 0.75rem; 850 + border-bottom: 1px solid var(--border); 851 + font-weight: 500; 852 + }} 853 + 854 + .mst-node-data pre {{ 855 + margin: 0; 856 + padding: 0.75rem; 857 + font-size: 0.625rem; 858 + color: var(--text); 859 + white-space: pre-wrap; 860 + word-break: break-word; 861 + line-height: 1.5; 647 862 }} 648 863 649 864 .record-list {{
+414 -14
static/app.js
··· 307 307 item.addEventListener('click', (e) => { 308 308 e.stopPropagation(); 309 309 const lexicon = item.dataset.lexicon; 310 - const existingRecords = item.querySelector('.record-list'); 310 + const existingContent = item.querySelector('.collection-content'); 311 311 312 - if (existingRecords) { 313 - existingRecords.remove(); 312 + if (existingContent) { 313 + existingContent.remove(); 314 314 return; 315 315 } 316 316 317 - const recordListDiv = document.createElement('div'); 318 - recordListDiv.className = 'record-list'; 319 - recordListDiv.innerHTML = '<div class="loading">loading records...</div>'; 320 - item.appendChild(recordListDiv); 317 + // Create container for tabs and content 318 + const contentDiv = document.createElement('div'); 319 + contentDiv.className = 'collection-content'; 320 + 321 + // Will add tabs after we know record count 322 + contentDiv.innerHTML = ` 323 + <div class="collection-view-content"> 324 + <div class="collection-view records-view active"> 325 + <div class="loading">loading records...</div> 326 + </div> 327 + <div class="collection-view structure-view"> 328 + <div class="loading">loading structure...</div> 329 + </div> 330 + </div> 331 + `; 332 + item.appendChild(contentDiv); 321 333 322 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5`) 334 + const recordsView = contentDiv.querySelector('.records-view'); 335 + const structureView = contentDiv.querySelector('.structure-view'); 336 + 337 + // Load records first to determine if we should show structure tab 338 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 323 339 .then(r => r.json()) 324 340 .then(data => { 341 + // Add tabs if there are enough records for structure view 342 + const hasEnoughRecords = data.records && data.records.length >= 5; 343 + if (hasEnoughRecords) { 344 + const tabsHtml = ` 345 + <div class="collection-tabs"> 346 + <button class="collection-tab active" data-tab="records">records</button> 347 + <button class="collection-tab" data-tab="structure">mst</button> 348 + </div> 349 + `; 350 + contentDiv.insertAdjacentHTML('afterbegin', tabsHtml); 351 + 352 + // Tab switching logic 353 + contentDiv.querySelectorAll('.collection-tab').forEach(tab => { 354 + tab.addEventListener('click', (e) => { 355 + e.stopPropagation(); 356 + const tabName = tab.dataset.tab; 357 + 358 + // Update active tab 359 + contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active')); 360 + tab.classList.add('active'); 361 + 362 + // Update active view 363 + contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active')); 364 + if (tabName === 'records') { 365 + recordsView.classList.add('active'); 366 + } else if (tabName === 'structure') { 367 + structureView.classList.add('active'); 368 + // Load structure if not already loaded 369 + if (structureView.querySelector('.loading')) { 370 + loadMSTStructure(lexicon, structureView); 371 + } 372 + } 373 + }); 374 + }); 375 + } 376 + 325 377 if (data.records && data.records.length > 0) { 326 378 let recordsHtml = ''; 327 379 data.records.forEach((record, idx) => { ··· 344 396 recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 345 397 } 346 398 347 - recordListDiv.innerHTML = recordsHtml; 399 + recordsView.innerHTML = recordsHtml; 348 400 349 401 // Use event delegation for copy and load more buttons 350 - recordListDiv.addEventListener('click', (e) => { 402 + recordsView.addEventListener('click', (e) => { 351 403 // Handle copy button 352 404 if (e.target.classList.contains('copy-btn')) { 353 405 e.stopPropagation(); ··· 401 453 }); 402 454 403 455 loadMoreBtn.remove(); 404 - recordListDiv.insertAdjacentHTML('beforeend', moreHtml); 456 + recordsView.insertAdjacentHTML('beforeend', moreHtml); 405 457 406 458 if (moreData.cursor && moreData.records.length === 5) { 407 - recordListDiv.insertAdjacentHTML('beforeend', 459 + recordsView.insertAdjacentHTML('beforeend', 408 460 `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>` 409 461 ); 410 462 } ··· 412 464 } 413 465 }); 414 466 } else { 415 - recordListDiv.innerHTML = '<div class="record">no records found</div>'; 467 + recordsView.innerHTML = '<div class="record">no records found</div>'; 416 468 } 417 469 }) 418 470 .catch(e => { 419 471 console.error('Error fetching records:', e); 420 - recordListDiv.innerHTML = '<div class="record">error loading records</div>'; 472 + recordsView.innerHTML = '<div class="record">error loading records</div>'; 421 473 }); 422 474 }); 423 475 }); ··· 438 490 document.getElementById('field').innerHTML = 'error loading records'; 439 491 console.error(e); 440 492 }); 493 + 494 + // MST Visualization Functions 495 + async function loadMSTStructure(lexicon, containerView) { 496 + try { 497 + // Fetch records (up to 100 for visualization) 498 + const response = await fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=100`); 499 + const data = await response.json(); 500 + 501 + if (!data.records || data.records.length === 0) { 502 + containerView.innerHTML = '<div class="mst-info"><p>no records to visualize</p></div>'; 503 + return; 504 + } 505 + 506 + // Extract record keys (rkeys) and keep full record data 507 + const records = data.records.map(r => ({ 508 + key: r.uri.split('/').pop(), 509 + cid: r.cid, 510 + uri: r.uri, 511 + value: r.value 512 + })); 513 + 514 + // Build simplified MST 515 + const mst = buildSimplifiedMST(records); 516 + 517 + // Render structure 518 + containerView.innerHTML = ` 519 + <div class="mst-info"> 520 + <p>this shows the <a href="https://atproto.com/specs/repository#mst-structure" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Merkle Search Tree (MST)</a> structure used to store your ${records.length} record${records.length !== 1 ? 's' : ''} in your repository. records are organized by their <a href="https://atproto.com/specs/record-key#record-key-type-tid" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">TIDs</a> (timestamp identifiers), which determines how they're arranged in the tree.</p> 521 + </div> 522 + <canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas> 523 + `; 524 + 525 + // Render tree on canvas 526 + setTimeout(() => { 527 + const canvas = containerView.querySelector('.mst-canvas'); 528 + if (canvas) { 529 + renderMSTTree(canvas, mst); 530 + } 531 + }, 50); 532 + 533 + } catch (e) { 534 + console.error('Error loading MST structure:', e); 535 + containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>'; 536 + } 537 + } 538 + 539 + function buildSimplifiedMST(records) { 540 + // Sort records by key (TIDs are lexicographically sortable) 541 + records.sort((a, b) => a.key.localeCompare(b.key)); 542 + 543 + // Calculate depth for each key and keep full record 544 + const nodes = records.map(r => ({ 545 + key: r.key, 546 + cid: r.cid, 547 + uri: r.uri, 548 + value: r.value, 549 + depth: calculateKeyDepth(r.key) 550 + })); 551 + 552 + // Build tree structure 553 + return buildTree(nodes); 554 + } 555 + 556 + function calculateKeyDepth(key) { 557 + // Simplified depth calculation based on key hash 558 + let hash = 0; 559 + for (let i = 0; i < key.length; i++) { 560 + hash = ((hash << 5) - hash) + key.charCodeAt(i); 561 + hash = hash & hash; 562 + } 563 + 564 + // Count leading zero bits (approximation) 565 + const absHash = Math.abs(hash); 566 + const binary = absHash.toString(2).padStart(32, '0'); 567 + 568 + let depth = 0; 569 + for (let i = 0; i < binary.length; i += 2) { 570 + if (binary.substr(i, 2) === '00') { 571 + depth++; 572 + } else { 573 + break; 574 + } 575 + } 576 + 577 + return Math.min(depth, 5); // Cap at depth 5 578 + } 579 + 580 + function buildTree(nodes) { 581 + // Build a simple tree structure for visualization 582 + const root = { depth: -1, children: [], key: 'root', cid: null }; 583 + 584 + const byDepth = {}; 585 + nodes.forEach(node => { 586 + if (!byDepth[node.depth]) byDepth[node.depth] = []; 587 + byDepth[node.depth].push(node); 588 + }); 589 + 590 + // Create hierarchical structure 591 + let currentLevel = [root]; 592 + Object.keys(byDepth).sort((a, b) => parseInt(a) - parseInt(b)).forEach(depth => { 593 + const nodesAtDepth = byDepth[depth]; 594 + const nextLevel = []; 595 + 596 + nodesAtDepth.forEach((node, idx) => { 597 + const parentIdx = Math.floor(idx / 2) % currentLevel.length; 598 + const parent = currentLevel[parentIdx]; 599 + if (!parent.children) parent.children = []; 600 + parent.children.push(node); 601 + nextLevel.push(node); 602 + }); 603 + 604 + currentLevel = nextLevel; 605 + }); 606 + 607 + return root; 608 + } 609 + 610 + function renderMSTTree(canvas, tree) { 611 + const ctx = canvas.getContext('2d'); 612 + const width = canvas.width = canvas.offsetWidth; 613 + const height = canvas.height = canvas.offsetHeight; 614 + 615 + // Calculate tree layout 616 + const layout = layoutTree(tree, width, height); 617 + 618 + // Get CSS colors 619 + const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim(); 620 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 621 + const textLightColor = getComputedStyle(document.documentElement).getPropertyValue('--text-light').trim(); 622 + const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim(); 623 + const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim(); 624 + const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim(); 625 + 626 + let hoveredNode = null; 627 + 628 + function draw() { 629 + // Clear canvas 630 + ctx.clearRect(0, 0, width, height); 631 + 632 + // Draw connections first 633 + layout.forEach(node => { 634 + if (node.children) { 635 + node.children.forEach(child => { 636 + ctx.beginPath(); 637 + ctx.moveTo(node.x, node.y); 638 + ctx.lineTo(child.x, child.y); 639 + ctx.strokeStyle = borderColor; 640 + ctx.lineWidth = 1; 641 + ctx.stroke(); 642 + }); 643 + } 644 + }); 645 + 646 + // Draw nodes 647 + layout.forEach(node => { 648 + const isRoot = node.depth === -1; 649 + const isLeaf = !node.children || node.children.length === 0; 650 + const isHovered = hoveredNode === node; 651 + 652 + // Node circle 653 + ctx.beginPath(); 654 + ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2); 655 + 656 + ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor; 657 + ctx.fill(); 658 + 659 + ctx.strokeStyle = isHovered ? textColor : borderColor; 660 + ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1; 661 + ctx.stroke(); 662 + }); 663 + 664 + // Draw label for hovered node 665 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 666 + const padding = 6; 667 + const fontSize = 10; 668 + ctx.font = `${fontSize}px monospace`; 669 + const textWidth = ctx.measureText(hoveredNode.key).width; 670 + 671 + // Position tooltip above node 672 + const tooltipX = hoveredNode.x; 673 + const tooltipY = hoveredNode.y - 20; 674 + const boxWidth = textWidth + padding * 2; 675 + const boxHeight = fontSize + padding * 2; 676 + 677 + // Draw tooltip background 678 + ctx.fillStyle = bgColor; 679 + ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 680 + 681 + // Draw tooltip border 682 + ctx.strokeStyle = borderColor; 683 + ctx.lineWidth = 1; 684 + ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 685 + 686 + // Draw text 687 + ctx.fillStyle = textColor; 688 + ctx.textAlign = 'center'; 689 + ctx.textBaseline = 'middle'; 690 + ctx.fillText(hoveredNode.key, tooltipX, tooltipY); 691 + } 692 + } 693 + 694 + // Mouse move handler 695 + canvas.addEventListener('mousemove', (e) => { 696 + const rect = canvas.getBoundingClientRect(); 697 + const mouseX = e.clientX - rect.left; 698 + const mouseY = e.clientY - rect.top; 699 + 700 + let foundNode = null; 701 + for (const node of layout) { 702 + const isRoot = node.depth === -1; 703 + const radius = isRoot ? 12 : 8; 704 + const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2); 705 + if (dist <= radius) { 706 + foundNode = node; 707 + break; 708 + } 709 + } 710 + 711 + if (foundNode !== hoveredNode) { 712 + hoveredNode = foundNode; 713 + canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; 714 + draw(); 715 + } 716 + }); 717 + 718 + // Mouse leave handler 719 + canvas.addEventListener('mouseleave', () => { 720 + if (hoveredNode) { 721 + hoveredNode = null; 722 + canvas.style.cursor = 'default'; 723 + draw(); 724 + } 725 + }); 726 + 727 + // Click handler 728 + canvas.addEventListener('click', (e) => { 729 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 730 + showNodeModal(hoveredNode); 731 + } 732 + }); 733 + 734 + // Initial draw 735 + draw(); 736 + } 737 + 738 + function showNodeModal(node) { 739 + // Create modal 740 + const modal = document.createElement('div'); 741 + modal.className = 'mst-node-modal'; 742 + modal.innerHTML = ` 743 + <div class="mst-node-modal-content"> 744 + <button class="mst-node-close">×</button> 745 + <h3>record in MST</h3> 746 + <div class="mst-node-info"> 747 + <div class="mst-node-field"> 748 + <span class="mst-node-label">TID:</span> 749 + <span class="mst-node-value">${node.key}</span> 750 + </div> 751 + <div class="mst-node-field"> 752 + <span class="mst-node-label">CID:</span> 753 + <span class="mst-node-value">${node.cid}</span> 754 + </div> 755 + ${node.uri ? ` 756 + <div class="mst-node-field"> 757 + <span class="mst-node-label">URI:</span> 758 + <span class="mst-node-value">${node.uri}</span> 759 + </div> 760 + ` : ''} 761 + </div> 762 + <div class="mst-node-explanation"> 763 + <p>this is a leaf node in your Merkle Search Tree. the TID (timestamp identifier) determines its position in the tree. records are sorted by TID, making range queries efficient.</p> 764 + </div> 765 + ${node.value ? ` 766 + <div class="mst-node-data"> 767 + <div class="mst-node-data-header">record data</div> 768 + <pre>${JSON.stringify(node.value, null, 2)}</pre> 769 + </div> 770 + ` : ''} 771 + </div> 772 + `; 773 + 774 + // Add to DOM 775 + document.body.appendChild(modal); 776 + 777 + // Close handlers 778 + modal.querySelector('.mst-node-close').addEventListener('click', () => { 779 + modal.remove(); 780 + }); 781 + 782 + modal.addEventListener('click', (e) => { 783 + if (e.target === modal) { 784 + modal.remove(); 785 + } 786 + }); 787 + } 788 + 789 + function layoutTree(tree, width, height) { 790 + const nodes = []; 791 + const padding = 40; 792 + const availableWidth = width - padding * 2; 793 + const availableHeight = height - padding * 2; 794 + 795 + // Calculate max depth and total nodes at each depth 796 + const depthCounts = {}; 797 + function countDepths(node, depth) { 798 + if (!depthCounts[depth]) depthCounts[depth] = 0; 799 + depthCounts[depth]++; 800 + if (node.children) { 801 + node.children.forEach(child => countDepths(child, depth + 1)); 802 + } 803 + } 804 + countDepths(tree, 0); 805 + 806 + const maxDepth = Math.max(...Object.keys(depthCounts).map(Number)); 807 + const verticalSpacing = availableHeight / (maxDepth + 1); 808 + 809 + // Track positions at each depth to avoid overlap 810 + const positionsByDepth = {}; 811 + 812 + function traverse(node, depth, minX, maxX) { 813 + if (!positionsByDepth[depth]) positionsByDepth[depth] = []; 814 + 815 + // Calculate position based on available space 816 + const x = (minX + maxX) / 2; 817 + const y = padding + verticalSpacing * depth; 818 + 819 + const layoutNode = { ...node, x, y }; 820 + nodes.push(layoutNode); 821 + positionsByDepth[depth].push(x); 822 + 823 + if (node.children && node.children.length > 0) { 824 + layoutNode.children = []; 825 + const childWidth = (maxX - minX) / node.children.length; 826 + 827 + node.children.forEach((child, idx) => { 828 + const childMinX = minX + childWidth * idx; 829 + const childMaxX = minX + childWidth * (idx + 1); 830 + const childLayout = traverse(child, depth + 1, childMinX, childMaxX); 831 + layoutNode.children.push(childLayout); 832 + }); 833 + } 834 + 835 + return layoutNode; 836 + } 837 + 838 + traverse(tree, 0, padding, width - padding); 839 + return nodes; 840 + }