Files
ladybird/Meta/gc-heap-explorer.html
Zaggy1024 07d1b222c7 Meta: Simplify GC heap explorer's class list rendering
We don't need to pass the filter and sort to the render function from
all these locations, the callers always use values from the same
inputs. Instead, just use the values directly from cached references
the input elements.

This fixes the class filter not being applied correctly after a refresh
autofills the old value back into it.
2026-03-01 21:50:51 +01:00

2035 lines
66 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ladybird GC Heap Explorer</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
:root {
--bg-primary: #0a0e14;
--bg-secondary: #0f1419;
--bg-tertiary: #151b23;
--bg-elevated: #1a222d;
--border-color: #2d3a4a;
--border-highlight: #3d4f66;
--text-primary: #e6e8eb;
--text-secondary: #8a919a;
--text-muted: #5c6370;
--accent-cyan: #00d4ff;
--accent-cyan-dim: #00a3c7;
--accent-orange: #ff9f43;
--accent-orange-dim: #cc7a2e;
--accent-purple: #b48ead;
--accent-green: #98c379;
--accent-red: #e06c75;
--accent-yellow: #e5c07b;
--root-color: #ff6b9d;
--node-default: #4a5568;
--graph-bg: #080c10;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Space Grotesk', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow: hidden;
}
.mono {
font-family: 'JetBrains Mono', monospace;
}
/* Drop Zone */
#drop-zone {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-primary);
z-index: 1000;
transition: opacity 0.3s ease;
}
#drop-zone.hidden {
opacity: 0;
pointer-events: none;
}
#drop-zone.drag-over {
background: var(--bg-secondary);
}
#drop-zone.drag-over .drop-border {
border-color: var(--accent-cyan);
box-shadow: 0 0 60px rgba(0, 212, 255, 0.2);
}
.drop-content {
text-align: center;
max-width: 600px;
}
.drop-border {
width: 400px;
height: 300px;
border: 2px dashed var(--border-color);
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 32px;
transition: all 0.3s ease;
background: var(--bg-secondary);
}
.drop-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.6;
}
.drop-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.drop-subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.file-input-wrapper {
margin-top: 24px;
}
.file-input-btn {
background: var(--accent-cyan);
color: var(--bg-primary);
border: none;
padding: 12px 32px;
font-size: 14px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.file-input-btn:hover {
background: var(--accent-cyan-dim);
transform: translateY(-1px);
}
#file-input {
display: none;
}
.brand {
position: absolute;
top: 32px;
left: 32px;
display: flex;
align-items: center;
gap: 12px;
}
.brand-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
}
.brand-text {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.5px;
}
.brand-sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
/* Main Layout */
#main-app {
display: none;
height: 100vh;
grid-template-columns: 320px 1fr 380px;
grid-template-rows: auto 1fr;
}
#main-app.visible {
display: grid;
}
/* Header */
.header {
grid-column: 1 / -1;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.header-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 15px;
}
.header-brand-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.file-name {
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 6px 12px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
}
.header-stats {
display: flex;
gap: 24px;
}
.stat-item {
display: flex;
align-items: baseline;
gap: 6px;
}
.stat-value {
font-size: 18px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value.cyan { color: var(--accent-cyan); }
.stat-value.orange { color: var(--accent-orange); }
.stat-value.purple { color: var(--accent-purple); }
.stat-value.green { color: var(--accent-green); }
.reload-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 8px 16px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.reload-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border-highlight);
}
/* Left Panel - Classes */
.left-panel {
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.search-input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 14px;
font-size: 13px;
border-radius: 6px;
font-family: inherit;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
}
.search-input::placeholder {
color: var(--text-muted);
}
.class-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.class-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
margin-bottom: 2px;
}
.class-item:hover {
background: var(--bg-tertiary);
}
.class-item.selected {
background: var(--bg-elevated);
border: 1px solid var(--border-highlight);
}
.class-item.root-class {
border-left: 3px solid var(--root-color);
}
.class-name {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin-right: 12px;
}
.class-count {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
background: rgba(0, 212, 255, 0.1);
padding: 3px 8px;
border-radius: 4px;
font-weight: 600;
}
.class-bar {
height: 3px;
background: var(--accent-cyan);
border-radius: 2px;
margin-top: 6px;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.class-item:hover .class-bar {
opacity: 1;
}
/* Center - Graph */
.graph-container {
background: var(--graph-bg);
position: relative;
overflow: hidden;
}
#graph-svg {
width: 100%;
height: 100%;
}
.graph-controls {
position: absolute;
bottom: 20px;
left: 20px;
display: flex;
gap: 8px;
z-index: 10;
}
.graph-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s ease;
}
.graph-btn:hover {
background: var(--bg-elevated);
border-color: var(--border-highlight);
}
.graph-info {
position: absolute;
top: 70px;
left: 20px;
background: rgba(15, 20, 25, 0.9);
border: 1px solid var(--border-color);
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
color: var(--text-secondary);
backdrop-filter: blur(8px);
}
.graph-legend {
position: absolute;
top: 20px;
right: 20px;
background: rgba(15, 20, 25, 0.9);
border: 1px solid var(--border-color);
padding: 12px 16px;
border-radius: 8px;
backdrop-filter: blur(8px);
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.legend-item:last-child {
margin-bottom: 0;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-dot.root { background: var(--root-color); }
.legend-dot.selected { background: var(--accent-orange); }
.legend-dot.connected { background: var(--accent-cyan); }
.legend-dot.default { background: var(--node-default); }
/* Right Panel - Details */
.right-panel {
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.detail-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.selected-node-info {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 12px;
}
.node-address {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: var(--accent-orange);
margin-bottom: 6px;
}
.node-class {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.node-root-badge {
display: inline-block;
background: var(--root-color);
color: var(--bg-primary);
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
margin-top: 8px;
}
.detail-section {
border-bottom: 1px solid var(--border-color);
}
.section-header {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background 0.2s ease;
}
.section-header:hover {
background: var(--bg-tertiary);
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.section-count {
background: var(--bg-elevated);
padding: 2px 8px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
}
.section-toggle {
color: var(--text-muted);
transition: transform 0.2s ease;
}
.section-content {
max-height: 200px;
overflow-y: auto;
padding: 0 8px 8px;
}
.section-content.collapsed {
display: none;
}
.edge-item {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
margin-bottom: 2px;
}
.edge-item:hover {
background: var(--bg-tertiary);
}
.edge-item.root {
border-left: 3px solid var(--root-color);
}
.edge-address {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
width: 100px;
flex-shrink: 0;
}
.edge-class {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Path to Root */
.path-section {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.path-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.path-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.path-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.path-node {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 0;
border-left: 2px solid var(--border-color);
margin-left: 8px;
padding-left: 16px;
position: relative;
}
.path-node::before {
content: '';
position: absolute;
left: -5px;
top: 14px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-color);
}
.path-node.root::before {
background: var(--root-color);
}
.path-node.target::before {
background: var(--accent-orange);
}
.path-node:first-child {
border-left-color: var(--root-color);
}
.path-node:last-child {
border-left-color: var(--accent-orange);
}
.path-node-info {
flex: 1;
}
.path-node-class {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.path-node-addr {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
}
.path-root-info {
font-size: 10px;
color: var(--root-color);
margin-top: 4px;
}
.no-selection {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 40px 20px;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-highlight);
}
/* Loading indicator */
.loading {
position: fixed;
inset: 0;
background: rgba(10, 14, 20, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2000;
}
.loading.hidden {
display: none;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 16px;
font-size: 14px;
color: var(--text-secondary);
}
/* Graph nodes and links */
.node circle {
stroke-width: 2px;
cursor: pointer;
transition: r 0.2s ease;
}
.node:hover circle {
r: 10;
}
.node text {
font-family: 'JetBrains Mono', monospace;
font-size: 7px;
fill: var(--text-secondary);
pointer-events: none;
}
.link {
stroke: var(--border-color);
stroke-opacity: 0.3;
fill: none;
}
.link.highlighted {
stroke: var(--accent-cyan);
stroke-opacity: 0.8;
stroke-width: 2px;
}
.link.path-highlight {
stroke: var(--accent-orange);
stroke-opacity: 1;
stroke-width: 3px;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
}
.tab {
flex: 1;
padding: 12px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
}
.tab:hover {
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.tab.active {
color: var(--accent-cyan);
border-bottom-color: var(--accent-cyan);
}
.tab-content {
display: none;
flex: 1;
overflow: hidden;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
/* Treemap styles */
.treemap-container {
flex: 1;
padding: 8px;
overflow: hidden;
}
#treemap-svg {
width: 100%;
height: 100%;
}
.treemap-cell {
cursor: pointer;
transition: opacity 0.2s ease;
}
.treemap-cell:hover {
opacity: 0.8;
}
.treemap-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
fill: var(--text-primary);
pointer-events: none;
}
/* Filter buttons */
.filter-row {
display: flex;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
}
.filter-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
font-size: 11px;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s ease;
}
.filter-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--accent-cyan);
color: var(--bg-primary);
border-color: var(--accent-cyan);
}
/* Root type badges */
.root-type {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
background: rgba(255, 107, 157, 0.2);
color: var(--root-color);
margin-left: 8px;
}
/* Stack trace */
.stack-frame-item {
display: flex;
align-items: baseline;
padding: 6px 10px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 1px;
gap: 8px;
}
.stack-frame-item.highlighted {
background: rgba(0, 212, 255, 0.12);
color: var(--accent-cyan-dim);
border-left: 3px solid var(--accent-cyan);
}
.stack-frame-index {
min-width: 24px;
text-align: right;
flex-shrink: 0;
}
.stack-frame-size {
min-width: 32px;
text-align: right;
flex-shrink: 0;
font-size: 10px;
}
.stack-frame-symbol {
color: var(--text-primary);
}
.stack-frame-item.highlighted .stack-frame-symbol {
color: var(--accent-cyan);
}
.stack-frame-item.inline {
color: var(--text-muted);
}
.stack-frame-item.inline.highlighted {
color: var(--text-secondary);
}
.stack-frame-item.inline .stack-frame-size {
text-align: center;
}
.stack-frame-item.inline .stack-frame-symbol {
color: var(--text-secondary);
}
.root-category-header {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 6px;
transition: background 0.15s ease;
}
.root-category-header:hover {
background: var(--bg-elevated) !important;
}
.root-category-header .class-name {
flex: 1;
}
.root-category-content {
margin-bottom: 8px;
}
/* Edge direction toggle */
.graph-mode-toggle {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
background: rgba(15, 20, 25, 0.95);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
backdrop-filter: blur(8px);
}
.mode-btn {
padding: 10px 20px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
display: flex;
align-items: center;
gap: 6px;
}
.mode-btn:first-child {
border-right: 1px solid var(--border-color);
}
.mode-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.mode-btn.active {
color: var(--accent-cyan);
background: rgba(0, 212, 255, 0.1);
}
.mode-btn .arrow {
font-size: 14px;
}
/* Address search */
.address-search {
display: flex;
gap: 8px;
}
.address-input {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
border-radius: 6px;
width: 180px;
transition: all 0.2s ease;
}
.address-input:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
}
.address-input::placeholder {
color: var(--text-muted);
font-family: 'Space Grotesk', sans-serif;
}
.address-search-btn {
background: var(--accent-cyan);
color: var(--bg-primary);
border: none;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s ease;
}
.address-search-btn:hover {
background: var(--accent-cyan-dim);
}
</style>
</head>
<body>
<!-- Loading indicator -->
<div id="loading" class="loading hidden">
<div class="spinner"></div>
<div class="loading-text">Processing heap dump...</div>
</div>
<!-- Drop Zone -->
<div id="drop-zone">
<div class="brand">
<div class="brand-icon">GC</div>
<div>
<div class="brand-text">Ladybird GC Heap Explorer</div>
<div class="brand-sub">Visualize and analyze garbage collector snapshots</div>
</div>
</div>
<div class="drop-content">
<div class="drop-border">
<div class="drop-icon">&#128202;</div>
<div class="drop-title">Drop GC Dump Here</div>
<div class="drop-subtitle">JSON file from LibGC heap dump</div>
</div>
<div class="file-input-wrapper">
<button class="file-input-btn" onclick="document.getElementById('file-input').click()">
Choose File
</button>
<input type="file" id="file-input" accept=".json,.js">
</div>
</div>
</div>
<!-- Main Application -->
<div id="main-app">
<!-- Header -->
<header class="header">
<div class="header-left">
<div class="header-brand">
<div class="header-brand-icon">GC</div>
<span>Heap Explorer</span>
</div>
<div class="file-name" id="file-name">-</div>
</div>
<div class="header-stats">
<div class="stat-item">
<span class="stat-value cyan" id="stat-nodes">0</span>
<span class="stat-label">Nodes</span>
</div>
<div class="stat-item">
<span class="stat-value orange" id="stat-roots">0</span>
<span class="stat-label">Roots</span>
</div>
<div class="stat-item">
<span class="stat-value purple" id="stat-edges">0</span>
<span class="stat-label">Edges</span>
</div>
<div class="stat-item">
<span class="stat-value green" id="stat-classes">0</span>
<span class="stat-label">Classes</span>
</div>
</div>
<div class="address-search">
<input type="text" id="address-input" class="address-input" placeholder="Find by address (0x...)">
<button class="address-search-btn" onclick="searchByAddress()">Go</button>
</div>
<button class="reload-btn" onclick="location.reload()">Load New File</button>
</header>
<!-- Left Panel -->
<aside class="left-panel">
<div class="tabs">
<div class="tab active" data-tab="classes">Classes</div>
<div class="tab" data-tab="roots">Roots</div>
</div>
<div class="tab-content active" id="tab-classes">
<div class="search-box">
<input type="text" class="search-input" id="class-search" placeholder="Filter classes...">
</div>
<div class="filter-row">
<button class="filter-btn active" data-sort="count">By Count</button>
<button class="filter-btn" data-sort="name">By Name</button>
<button class="filter-btn" data-sort="edges">By Edges</button>
</div>
<div class="class-list" id="class-list"></div>
</div>
<div class="tab-content" id="tab-roots">
<div class="search-box">
<input type="text" class="search-input" id="root-search" placeholder="Filter roots...">
</div>
<div class="class-list" id="root-list"></div>
</div>
</aside>
<!-- Graph Container -->
<main class="graph-container">
<svg id="graph-svg"></svg>
<div class="graph-mode-toggle">
<button class="mode-btn active" id="mode-outgoing" onclick="setGraphMode('outgoing')">
<span class="arrow">&#8594;</span> Outgoing
</button>
<button class="mode-btn" id="mode-incoming" onclick="setGraphMode('incoming')">
<span class="arrow">&#8592;</span> Incoming
</button>
</div>
<div class="graph-controls">
<button class="graph-btn" onclick="zoomIn()" title="Zoom In">+</button>
<button class="graph-btn" onclick="zoomOut()" title="Zoom Out">-</button>
<button class="graph-btn" onclick="resetZoom()" title="Reset View">&#8634;</button>
<button class="graph-btn" onclick="toggleSimulation()" title="Pause/Resume" id="pause-btn">&#9208;</button>
</div>
<div class="graph-info" id="graph-info">
Click a class to visualize its instances
</div>
<div class="graph-legend">
<div class="legend-item"><div class="legend-dot root"></div> Root node</div>
<div class="legend-item"><div class="legend-dot selected"></div> Selected</div>
<div class="legend-item"><div class="legend-dot connected"></div> Connected</div>
<div class="legend-item"><div class="legend-dot default"></div> Other</div>
</div>
</main>
<!-- Right Panel -->
<aside class="right-panel">
<div class="detail-header">
<div class="detail-title">Node Details</div>
<div id="selected-node-display">
<div class="no-selection">Select a node to view details</div>
</div>
</div>
<div class="detail-section" id="stack-trace-section" style="display: none;">
<div class="section-header" onclick="toggleSection('stack-trace')">
<div class="section-title">
Stack Trace <span class="section-count" id="stack-trace-count">0</span>
</div>
<span class="section-toggle">&#9660;</span>
</div>
<div class="section-content collapsed" id="stack-trace-content" style="max-height: 300px;"></div>
</div>
<div class="detail-section" id="outgoing-section">
<div class="section-header" onclick="toggleSection('outgoing')">
<div class="section-title">
Outgoing Edges <span class="section-count" id="outgoing-count">0</span>
</div>
<span class="section-toggle">&#9660;</span>
</div>
<div class="section-content" id="outgoing-content"></div>
</div>
<div class="detail-section" id="incoming-section">
<div class="section-header" onclick="toggleSection('incoming')">
<div class="section-title">
Incoming Edges <span class="section-count" id="incoming-count">0</span>
</div>
<span class="section-toggle">&#9660;</span>
</div>
<div class="section-content" id="incoming-content"></div>
</div>
<div class="path-section">
<div class="path-header">
<span class="path-title">Path to Root</span>
</div>
<div class="path-content" id="path-content">
<div class="no-selection">Select a node to see its retention path</div>
</div>
</div>
</aside>
</div>
<script>
// Global state
let heapData = null;
let stackFrames = null;
let stackFrameElements = null;
let highlightedFrameIndex = undefined;
let classStats = null;
let reverseEdges = null;
let rootNodes = null;
let selectedNode = null;
let selectedClass = null;
let simulation = null;
let svg, g, zoom;
let isPaused = false;
let graphMode = 'outgoing'; // 'outgoing' or 'incoming'
// DOM elements
const dropZone = document.getElementById('drop-zone');
const mainApp = document.getElementById('main-app');
const fileInput = document.getElementById('file-input');
const loading = document.getElementById('loading');
const classSearchInput = document.getElementById('class-search');
const rootSearchInput = document.getElementById('root-search');
let currentFilterButton = document.querySelector('.filter-btn.active');
// File handling
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) loadFile(file);
});
function loadFile(file) {
loading.classList.remove('hidden');
document.getElementById('file-name').textContent = file.name;
const reader = new FileReader();
reader.onload = (e) => {
try {
let text = e.target.result;
// Strip "var GC_GRAPH_DUMP = " prefix and trailing ";\n" if present (.js format)
const prefix = 'var GC_GRAPH_DUMP = ';
if (text.startsWith(prefix))
text = text.slice(prefix.length).replace(/;\s*$/, '');
const data = JSON.parse(text);
processData(data);
dropZone.classList.add('hidden');
mainApp.classList.add('visible');
loading.classList.add('hidden');
} catch (err) {
alert('Error parsing JSON: ' + err.message);
loading.classList.add('hidden');
}
};
reader.readAsText(file);
}
function processData(data) {
// Extract stack_frames before treating data as a node map
stackFrames = data.stack_frames || null;
delete data.stack_frames;
heapData = data;
// Build reverse edges map
reverseEdges = {};
for (const addr in data) {
reverseEdges[addr] = [];
}
for (const addr in data) {
const node = data[addr];
for (const edge of (node.edges || [])) {
if (reverseEdges[edge]) {
reverseEdges[edge].push(addr);
}
}
}
// Collect root nodes
rootNodes = {};
for (const addr in data) {
if (data[addr].root) {
rootNodes[addr] = data[addr].root;
}
}
// Compute class statistics
classStats = {};
let totalEdges = 0;
for (const addr in data) {
const node = data[addr];
const className = node.class_name;
if (!classStats[className]) {
classStats[className] = { count: 0, edges: 0, hasRoot: false, instances: [] };
}
classStats[className].count++;
classStats[className].edges += (node.edges || []).length;
classStats[className].instances.push(addr);
if (node.root) classStats[className].hasRoot = true;
totalEdges += (node.edges || []).length;
}
// Update stats
document.getElementById('stat-nodes').textContent = Object.keys(data).length.toLocaleString();
document.getElementById('stat-roots').textContent = Object.keys(rootNodes).length.toLocaleString();
document.getElementById('stat-edges').textContent = totalEdges.toLocaleString();
document.getElementById('stat-classes').textContent = Object.keys(classStats).length.toLocaleString();
// Render UI
renderStackTrace();
renderClassList();
renderRootList();
setupTabs();
initGraph();
}
function renderClassList() {
const filter = classSearchInput.value;
const sortBy = currentFilterButton.dataset.sort;
const list = document.getElementById('class-list');
const maxCount = Math.max(...Object.values(classStats).map(s => s.count));
let entries = Object.entries(classStats);
// Filter
if (filter) {
entries = entries.filter(([name]) =>
name.toLowerCase().includes(filter.toLowerCase())
);
}
// Sort
if (sortBy === 'count') {
entries.sort((a, b) => b[1].count - a[1].count);
} else if (sortBy === 'name') {
entries.sort((a, b) => a[0].localeCompare(b[0]));
} else if (sortBy === 'edges') {
entries.sort((a, b) => b[1].edges - a[1].edges);
}
list.innerHTML = entries.map(([name, stats]) => `
<div class="class-item ${stats.hasRoot ? 'root-class' : ''} ${selectedClass === name ? 'selected' : ''}"
onclick="selectClass('${name}')">
<div style="flex: 1; min-width: 0;">
<div class="class-name">${escapeHtml(name)}</div>
<div class="class-bar" style="width: ${(stats.count / maxCount) * 100}%"></div>
</div>
<span class="class-count">${stats.count.toLocaleString()}</span>
</div>
`).join('');
}
function renderRootList() {
const filter = rootSearchInput.value;
const list = document.getElementById('root-list');
// Group roots by type
const rootsByType = {};
for (const addr in rootNodes) {
const rootType = rootNodes[addr];
const shortType = rootType.split(' ')[0];
if (!rootsByType[shortType]) {
rootsByType[shortType] = [];
}
rootsByType[shortType].push({ addr, fullType: rootType, node: heapData[addr] });
}
let html = '';
let categoryIndex = 0;
for (const [type, roots] of Object.entries(rootsByType).sort((a, b) => b[1].length - a[1].length)) {
if (filter && !type.toLowerCase().includes(filter.toLowerCase())) continue;
const categoryId = `root-category-${categoryIndex++}`;
html += `<div class="root-category-header" onclick="toggleRootCategory('${categoryId}')" style="background: var(--bg-tertiary); margin-bottom: 2px; cursor: pointer;">
<span class="section-toggle" id="${categoryId}-toggle" style="margin-right: 8px; display: inline-block; transition: transform 0.2s;">&#9654;</span>
<span class="class-name" style="color: var(--root-color)">${escapeHtml(type)}</span>
<span class="class-count">${roots.length}</span>
</div>`;
html += `<div id="${categoryId}" class="root-category-content" style="display: none;">`;
for (const root of roots) {
html += `<div class="edge-item root" onclick="selectNode('${root.addr}')">
<span class="edge-address">${formatAddress(root.addr)}</span>
<span class="edge-class">${escapeHtml(root.node.class_name)}</span>
</div>`;
}
html += `</div>`;
}
list.innerHTML = html || '<div class="no-selection">No roots found</div>';
}
function toggleRootCategory(categoryId) {
const content = document.getElementById(categoryId);
const toggle = document.getElementById(categoryId + '-toggle');
if (content.style.display === 'none') {
content.style.display = 'block';
toggle.style.transform = 'rotate(90deg)';
} else {
content.style.display = 'none';
toggle.style.transform = 'rotate(0deg)';
}
}
function setupTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
classSearchInput.addEventListener('input', renderClassList);
rootSearchInput.addEventListener('input', renderRootList);
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentFilterButton.classList.remove('active');
btn.classList.add('active');
currentFilterButton = btn;
renderClassList();
});
});
}
function selectClass(className) {
selectedClass = className;
renderClassList();
visualizeClass(className);
}
function initGraph() {
svg = d3.select('#graph-svg');
const width = svg.node().parentElement.clientWidth;
const height = svg.node().parentElement.clientHeight;
svg.selectAll('*').remove();
// Add arrow marker
svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 20)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.append('path')
.attr('d', 'M 0,-5 L 10,0 L 0,5')
.attr('fill', 'var(--border-color)');
g = svg.append('g');
zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on('zoom', (event) => {
g.attr('transform', event.transform);
// Show labels when zoomed in past 1.5x
const scale = event.transform.k;
const labelOpacity = scale > 0.8 ? Math.min((scale - 0.8) / 0.4, 1) : 0;
g.selectAll('.node-label').style('opacity', labelOpacity);
});
svg.call(zoom);
document.getElementById('graph-info').textContent =
`${Object.keys(heapData).length.toLocaleString()} nodes loaded. Select a class to visualize.`;
}
function visualizeClass(className) {
const stats = classStats[className];
if (!stats) return;
const width = svg.node().parentElement.clientWidth;
const height = svg.node().parentElement.clientHeight;
// Limit nodes for performance
const maxNodes = 500;
const instances = stats.instances.slice(0, maxNodes);
// Collect connected nodes based on mode
const nodeSet = new Set(instances);
const links = [];
for (const addr of instances) {
const node = heapData[addr];
if (graphMode === 'outgoing') {
// Show what this class references (outgoing edges)
for (const edge of (node.edges || [])) {
if (heapData[edge]) {
nodeSet.add(edge);
links.push({ source: addr, target: edge });
}
}
} else {
// Show what references this class (incoming edges)
for (const incoming of (reverseEdges[addr] || [])) {
nodeSet.add(incoming);
links.push({ source: incoming, target: addr });
}
}
}
// Limit total nodes
const nodes = Array.from(nodeSet).slice(0, maxNodes * 2).map(addr => ({
id: addr,
class_name: heapData[addr].class_name,
isRoot: !!heapData[addr].root,
isTarget: instances.includes(addr)
}));
const nodeIds = new Set(nodes.map(n => n.id));
const filteredLinks = links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
// Clear and redraw
g.selectAll('*').remove();
// Stop existing simulation
if (simulation) simulation.stop();
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(filteredLinks).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-150))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(25));
const link = g.append('g')
.selectAll('line')
.data(filteredLinks)
.join('line')
.attr('class', 'link')
.attr('marker-end', 'url(#arrowhead)');
const node = g.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('click', (event, d) => {
event.stopPropagation();
selectNode(d.id);
});
node.append('circle')
.attr('r', d => d.isTarget ? 8 : 6)
.attr('fill', d => {
if (d.isRoot) return 'var(--root-color)';
if (d.isTarget) return 'var(--accent-cyan)';
return 'var(--node-default)';
})
.attr('stroke', d => {
if (d.isRoot) return '#ff8ab5';
if (d.isTarget) return '#33ddff';
return '#6b7a8a';
});
node.append('title')
.text(d => `${d.class_name}\n${d.id}`);
node.append('text')
.attr('class', 'node-label')
.attr('dy', -12)
.attr('text-anchor', 'middle')
.text(d => d.class_name)
.style('opacity', 0);
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
const modeLabel = graphMode === 'outgoing' ? 'outgoing refs from' : 'incoming refs to';
document.getElementById('graph-info').textContent =
`${nodes.length} nodes, ${filteredLinks.length} ${modeLabel} "${className}"`;
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
}
function selectNode(addr) {
selectedNode = addr;
const node = heapData[addr];
if (!node) return;
// Update selected node display
const display = document.getElementById('selected-node-display');
display.innerHTML = `
<div class="selected-node-info">
<div class="node-address">${toHex(addr)}</div>
<div class="node-class">${escapeHtml(node.class_name)}</div>
${node.root ? `<div class="node-root-badge">ROOT: ${escapeHtml(rootLabel(node))}</div>` : ''}
</div>
`;
// Update outgoing edges
const outgoing = node.edges || [];
document.getElementById('outgoing-count').textContent = outgoing.length;
document.getElementById('outgoing-content').innerHTML = outgoing.map(edge => `
<div class="edge-item ${heapData[edge]?.root ? 'root' : ''}" onclick="selectNode('${edge}')">
<span class="edge-address">${formatAddress(edge)}</span>
<span class="edge-class">${escapeHtml(heapData[edge]?.class_name || 'Unknown')}</span>
</div>
`).join('') || '<div class="no-selection">No outgoing edges</div>';
// Update incoming edges
const incoming = reverseEdges[addr] || [];
document.getElementById('incoming-count').textContent = incoming.length;
document.getElementById('incoming-content').innerHTML = incoming.map(edge => `
<div class="edge-item ${heapData[edge]?.root ? 'root' : ''}" onclick="selectNode('${edge}')">
<span class="edge-address">${formatAddress(edge)}</span>
<span class="edge-class">${escapeHtml(heapData[edge]?.class_name || 'Unknown')}</span>
</div>
`).join('') || '<div class="no-selection">No incoming edges</div>';
// Highlight in graph
highlightNode(addr);
// Automatically find path to root
findPathToRoot();
}
function highlightNode(addr) {
g.selectAll('.node circle')
.attr('stroke-width', d => d.id === addr ? 4 : 2)
.attr('stroke', d => {
if (d.id === addr) return 'var(--accent-orange)';
if (d.isRoot) return '#ff8ab5';
if (d.isTarget) return '#33ddff';
return '#6b7a8a';
});
// Highlight connected links
g.selectAll('.link')
.classed('highlighted', d => d.source.id === addr || d.target.id === addr);
}
function renderStackTrace() {
const section = document.getElementById('stack-trace-section');
const content = document.getElementById('stack-trace-content');
const count = document.getElementById('stack-trace-count');
if (!stackFrames || stackFrames.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
count.textContent = stackFrames.length;
stackFrameElements = [];
for (let i = 0; i < stackFrames.length; i++) {
const frame = stackFrames[i];
const isInline = frame.size === 0;
const el = document.createElement('div');
el.className = 'stack-frame-item' + (isInline ? ' inline' : '');
el.innerHTML = `<span class="stack-frame-index">${i}</span>`
+ `<span class="stack-frame-size">${isInline ? '-' : formatSize(frame.size)}</span>`
+ `<span class="stack-frame-symbol">${escapeHtml(insertWordBreaks(frame.label) || '???')}</span>`;
content.appendChild(el);
stackFrameElements.push(el);
}
}
function updateStackTrace(frameIndex) {
if (!stackFrameElements) return;
if (frameIndex === highlightedFrameIndex) return;
if (frameIndex >= stackFrameElements.length) return;
if (highlightedFrameIndex !== undefined) {
stackFrameElements[highlightedFrameIndex].classList.remove('highlighted');
}
highlightedFrameIndex = frameIndex;
if (frameIndex !== undefined) {
stackFrameElements[frameIndex].classList.add('highlighted');
stackFrameElements[frameIndex].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
function findPathToRoot() {
if (!selectedNode) return;
const path = bfsToRoot(selectedNode);
const pathContent = document.getElementById('path-content');
if (!path) {
pathContent.innerHTML = '<div class="no-selection">No path to root found (node may be unreachable)</div>';
updateStackTrace(undefined);
return;
}
// Use the nearest root's stack frame index for the stack trace highlight
const rootNode = heapData[path[0]];
updateStackTrace(rootNode?.stack_frame_index);
pathContent.innerHTML = path.map((addr, i) => {
const node = heapData[addr];
const isRoot = i === 0;
const isTarget = i === path.length - 1;
return `
<div class="path-node ${isRoot ? 'root' : ''} ${isTarget ? 'target' : ''}"
onclick="selectNode('${addr}')" style="cursor: pointer;">
<div class="path-node-info">
<div class="path-node-class">${escapeHtml(node.class_name)}</div>
<div class="path-node-addr">${formatAddress(addr)}</div>
${node.root ? `<div class="path-root-info">${escapeHtml(rootLabel(node))}</div>` : ''}
</div>
</div>
`;
}).join('');
// Highlight path in graph
highlightPath(path);
}
function bfsToRoot(startAddr) {
const visited = new Set();
const queue = [[startAddr]];
while (queue.length > 0) {
const path = queue.shift();
const current = path[path.length - 1];
if (visited.has(current)) continue;
visited.add(current);
// Check if we've reached a root
if (heapData[current]?.root) {
return path.reverse(); // Return path from root to target
}
// Add all nodes that point to this one
for (const incoming of (reverseEdges[current] || [])) {
if (!visited.has(incoming)) {
queue.push([...path, incoming]);
}
}
}
return null; // No path found
}
function highlightPath(path) {
const pathSet = new Set(path);
const pathEdges = new Set();
for (let i = 0; i < path.length - 1; i++) {
pathEdges.add(`${path[i]}-${path[i + 1]}`);
}
g.selectAll('.node circle')
.attr('fill', d => {
if (pathSet.has(d.id)) {
if (heapData[d.id]?.root) return 'var(--root-color)';
if (d.id === selectedNode) return 'var(--accent-orange)';
return 'var(--accent-cyan)';
}
return d.isRoot ? 'var(--root-color)' : 'var(--node-default)';
})
.attr('stroke-width', d => pathSet.has(d.id) ? 4 : 2);
g.selectAll('.link')
.classed('path-highlight', d =>
pathEdges.has(`${d.source.id}-${d.target.id}`) ||
pathEdges.has(`${d.target.id}-${d.source.id}`)
)
.classed('highlighted', false);
}
function toggleSection(section) {
const content = document.getElementById(section + '-content');
content.classList.toggle('collapsed');
// When expanding the stack trace, scroll the highlighted frame into view
if (section === 'stack-trace') {
const highlighted = content.querySelector('.highlighted');
if (highlighted) {
highlighted.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
}
// Graph controls
function zoomIn() {
svg.transition().call(zoom.scaleBy, 1.5);
}
function zoomOut() {
svg.transition().call(zoom.scaleBy, 0.67);
}
function resetZoom() {
const width = svg.node().parentElement.clientWidth;
const height = svg.node().parentElement.clientHeight;
svg.transition().call(zoom.transform, d3.zoomIdentity.translate(width/2, height/2).scale(1));
}
function toggleSimulation() {
if (simulation) {
if (isPaused) {
simulation.alpha(0.3).restart();
document.getElementById('pause-btn').innerHTML = '&#9208;';
} else {
simulation.stop();
document.getElementById('pause-btn').innerHTML = '&#9654;';
}
isPaused = !isPaused;
}
}
function setGraphMode(mode) {
graphMode = mode;
document.getElementById('mode-outgoing').classList.toggle('active', mode === 'outgoing');
document.getElementById('mode-incoming').classList.toggle('active', mode === 'incoming');
// Re-visualize current class if one is selected
if (selectedClass) {
visualizeClass(selectedClass);
}
}
// Utility functions
const wordBreakRegEx = /(\W)(\w)/g;
function insertWordBreaks(text) {
return text.replace(wordBreakRegEx, '$1\u200B$2');
}
function rootLabel(node) {
const frame = stackFrames?.[node.stack_frame_index];
if (frame)
return `${node.root} ${frame.label}`;
return insertWordBreaks(node.root);
}
function formatSize(bytes) {
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'M';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'K';
return bytes + 'B';
}
function toHex(addr) {
return '0x' + BigInt(addr).toString(16).toUpperCase();
}
function formatAddress(addr) {
const hex = BigInt(addr).toString(16).toUpperCase();
return hex.length > 10 ? '...' + hex.slice(-8) : '0x' + hex;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function searchByAddress() {
const input = document.getElementById('address-input').value.trim();
if (!input || !heapData) return;
let decimalAddr;
try {
// Handle hex (0x...) or decimal input
if (input.toLowerCase().startsWith('0x')) {
decimalAddr = BigInt(input).toString();
} else if (/^[0-9a-fA-F]+$/.test(input) && input.length > 10) {
// Looks like hex without prefix
decimalAddr = BigInt('0x' + input).toString();
} else {
decimalAddr = input;
}
} catch (e) {
alert('Invalid address format');
return;
}
if (heapData[decimalAddr]) {
selectNode(decimalAddr);
// Also select the class to show it in the graph
const className = heapData[decimalAddr].class_name;
selectedClass = className;
renderClassList();
visualizeClass(className);
} else {
alert('Address not found in heap dump');
}
}
// Allow Enter key to trigger search
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('address-input')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') searchByAddress();
});
});
// Auto-load heap data via <script> tag to avoid CORS issues with file:// URLs.
// Usage: ?script=file:///path/to/gc-graph.js (the .js file sets var GC_GRAPH_DUMP = {...})
(function() {
const params = new URLSearchParams(window.location.search);
const scriptUrl = params.get('script');
if (scriptUrl) {
loading.classList.remove('hidden');
const script = document.createElement('script');
script.src = scriptUrl;
script.onload = function() {
if (typeof GC_GRAPH_DUMP !== 'undefined') {
const name = scriptUrl.split('/').pop().replace('.js', '.json') || 'heap.json';
document.getElementById('file-name').textContent = name;
processData(GC_GRAPH_DUMP);
dropZone.classList.add('hidden');
mainApp.classList.add('visible');
} else {
alert('Script loaded but GC_GRAPH_DUMP was not defined');
}
loading.classList.add('hidden');
};
script.onerror = function() {
alert('Failed to load script: ' + scriptUrl);
loading.classList.add('hidden');
};
document.head.appendChild(script);
}
})();
// Handle window resize
window.addEventListener('resize', () => {
if (selectedClass) {
visualizeClass(selectedClass);
}
});
</script>
</body>
</html>