<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta http-equiv="Content-Security-Policy" content="default-src 'self' file: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' file: data: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: file:; connect-src 'self';"><title>Loctree Report</title><style>
/* ============================================
loctree Report — Vista Galaxy Black Steel
============================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
/* ============================================
Theme: Light Mode (Default)
============================================ */
:root {
/* Light Theme Tokens */
--theme-bg-deep: #f5f7fa;
--theme-bg-surface: #ffffff;
--theme-bg-surface-elevated: #fafbfc;
--theme-text-primary: #1a1f26;
--theme-text-secondary: #4a5568;
--theme-text-tertiary: #718096;
--theme-accent: #3182ce;
--theme-accent-rgb: 49, 130, 206;
--theme-border: rgba(0, 0, 0, 0.1);
--theme-border-strong: rgba(0, 0, 0, 0.15);
--theme-hover: rgba(0, 0, 0, 0.04);
--theme-hover-strong: rgba(0, 0, 0, 0.08);
/* Scrollbar (Light) */
--theme-scrollbar: rgba(0, 0, 0, 0.15);
--theme-scrollbar-hover: rgba(0, 0, 0, 0.25);
/* Fallbacks for theme-aware scrollbars */
--scrollbar-bg: var(--theme-scrollbar, rgba(0, 0, 0, 0.15));
--scrollbar-bg-hover: var(--theme-scrollbar-hover, rgba(0, 0, 0, 0.25));
/* Gradients (Light) */
--gradient-nav: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(245,247,250,0.95) 100%);
--gradient-sidebar: linear-gradient(180deg, rgba(250,251,252,0.98) 0%, rgba(245,247,250,0.95) 100%);
--gradient-main: linear-gradient(180deg, rgba(250,251,252,0.95) 0%, rgba(245,247,250,0.9) 100%);
/* Dimensions */
--radius-lg: 20px;
--radius-md: 12px;
--radius-sm: 6px;
--sidebar-width: 280px;
--header-height: 68px;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
color-scheme: light dark;
}
/* Tooltip safety layer */
.tooltip-floating {
z-index: 9999 !important;
}
/* ============================================
Theme: Dark Mode (Vista Galaxy Black Steel)
============================================ */
.dark,
html.dark {
--theme-bg-deep: #0a0a0e;
--theme-bg-surface: #14171c;
--theme-bg-surface-elevated: #191d22;
--theme-text-primary: #e5ecf5;
--theme-text-secondary: #b2c0d4;
--theme-text-tertiary: #8897ad;
--theme-accent: #a3b8c7;
--theme-accent-rgb: 163, 184, 199;
--theme-border: rgba(114, 124, 139, 0.18);
--theme-border-strong: rgba(114, 124, 139, 0.28);
--theme-hover: rgba(255, 255, 255, 0.03);
--theme-hover-strong: rgba(255, 255, 255, 0.06);
/* Scrollbar (Dark) */
--theme-scrollbar: rgba(255, 255, 255, 0.15);
--theme-scrollbar-hover: rgba(255, 255, 255, 0.25);
--scrollbar-bg: var(--theme-scrollbar, rgba(255, 255, 255, 0.15));
--scrollbar-bg-hover: var(--theme-scrollbar-hover, rgba(255, 255, 255, 0.25));
/* Gradients (Dark) */
--gradient-nav: linear-gradient(135deg, rgba(10,10,14,0.95) 0%, rgba(32,36,44,0.85) 40%, rgba(120,132,144,0.15) 100%);
--gradient-sidebar: linear-gradient(180deg, rgba(12,12,16,0.95) 0%, rgba(24,28,34,0.9) 100%);
--gradient-main: linear-gradient(180deg, rgba(12,12,16,0.95) 0%, rgba(16,18,22,0.9) 55%, rgba(22,24,30,0.85) 100%);
}
/* Auto Dark Mode based on system preference */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--theme-bg-deep: #0a0a0e;
--theme-bg-surface: #14171c;
--theme-bg-surface-elevated: #191d22;
--theme-text-primary: #e5ecf5;
--theme-text-secondary: #b2c0d4;
--theme-text-tertiary: #8897ad;
--theme-accent: #a3b8c7;
--theme-accent-rgb: 163, 184, 199;
--theme-border: rgba(114, 124, 139, 0.18);
--theme-border-strong: rgba(114, 124, 139, 0.28);
--theme-hover: rgba(255, 255, 255, 0.03);
--theme-hover-strong: rgba(255, 255, 255, 0.06);
--gradient-nav: linear-gradient(135deg, rgba(10,10,14,0.95) 0%, rgba(32,36,44,0.85) 40%, rgba(120,132,144,0.15) 100%);
--gradient-sidebar: linear-gradient(180deg, rgba(12,12,16,0.95) 0%, rgba(24,28,34,0.9) 100%);
--gradient-main: linear-gradient(180deg, rgba(12,12,16,0.95) 0%, rgba(16,18,22,0.9) 55%, rgba(22,24,30,0.85) 100%);
}
}
/* Reset & Base */
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--theme-bg-deep);
color: var(--theme-text-primary);
line-height: 1.5;
margin: 0;
height: 100vh;
overflow: hidden;
font-size: 13px;
}
a { color: inherit; text-decoration: none; }
code, pre { font-family: var(--font-mono); }
/* Layout Shell */
.app-shell {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--theme-bg-deep);
}
/* Sidebar */
.app-sidebar {
width: var(--sidebar-width);
background: var(--gradient-sidebar);
border-right: 1px solid var(--theme-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
z-index: 20;
}
.sidebar-header {
height: var(--header-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid var(--theme-border);
}
/* Theme Toggle Button */
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: var(--theme-hover);
border: 1px solid var(--theme-border);
color: var(--theme-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--theme-hover-strong);
color: var(--theme-text-primary);
border-color: var(--theme-border-strong);
}
/* Test Toggle Button */
.test-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 12px;
border-radius: var(--radius-sm);
background: var(--theme-hover);
border: 1px solid var(--theme-border);
color: var(--theme-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
font-weight: 500;
}
.test-toggle-btn:hover {
background: var(--theme-hover-strong);
color: var(--theme-text-primary);
border-color: var(--theme-border-strong);
}
#test-toggle-icon {
font-size: 16px;
transition: opacity 0.2s ease;
}
/* Show sun icon in dark mode, moon icon in light mode */
.theme-icon-light { display: block; }
.theme-icon-dark { display: none; }
.dark .theme-icon-light,
html.dark .theme-icon-light { display: none; }
.dark .theme-icon-dark,
html.dark .theme-icon-dark { display: block; }
@media (prefers-color-scheme: dark) {
:root:not(.light) .theme-icon-light { display: none; }
:root:not(.light) .theme-icon-dark { display: block; }
}
.logo-box {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 14px;
color: var(--theme-text-primary);
letter-spacing: 0.02em;
}
.logo-img {
width: 28px;
height: 28px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.logo-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Nav items styled like tab buttons - unified design */
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: var(--radius-lg);
color: var(--theme-text-secondary);
transition: all 0.2s ease;
font-size: 13px;
font-weight: 500;
border: none;
background: transparent;
cursor: pointer;
text-decoration: none;
/* Prevent label overflow */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.nav-item:hover {
background: var(--theme-hover-strong);
color: var(--theme-text-primary);
}
.nav-item.active {
background: var(--theme-bg-surface);
color: var(--theme-accent);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
.nav-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--theme-text-tertiary);
margin: 24px 14px 8px;
}
/* Main Area */
.app-main {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: var(--gradient-main);
min-width: 0; /* Prevent flex overflow */
}
/* Sticky Header */
.app-header {
height: var(--header-height);
flex-shrink: 0;
background: var(--gradient-nav);
border-bottom: 1px solid var(--theme-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
backdrop-filter: blur(12px);
z-index: 10;
}
.header-title h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--theme-text-primary);
}
.header-title p,
.header-path {
margin: 2px 0 0;
font-size: 11px;
color: var(--theme-text-tertiary);
font-family: var(--font-mono);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Header Stats Badges */
.header-stats {
display: flex;
gap: 8px;
}
.stat-badge {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 14px;
background: var(--theme-bg-surface);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
min-width: 60px;
}
.stat-badge-value {
font-size: 16px;
font-weight: 600;
color: var(--theme-accent);
font-family: var(--font-mono);
}
.stat-badge-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-text-tertiary);
margin-top: 2px;
}
/* Tabs */
.header-tabs {
display: flex;
gap: 6px;
background: rgba(0,0,0,0.2);
padding: 4px;
border-radius: var(--radius-md);
border: 1px solid var(--theme-border);
}
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
color: var(--theme-text-secondary);
cursor: pointer;
transition: all 0.2s;
background: transparent;
border: none;
/* Prevent label overflow */
white-space: nowrap;
flex-shrink: 0;
}
.tab-btn:hover {
color: var(--theme-text-primary);
background: var(--theme-hover);
}
.tab-btn.active {
background: rgba(163, 184, 199, 0.15);
color: var(--theme-accent);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
/* Content Scroll Area */
.app-content {
flex: 1;
overflow-y: auto;
padding: 32px;
scroll-behavior: smooth;
}
/* Content Panels */
.content-container {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel {
background: var(--theme-bg-surface);
border: 1px solid var(--theme-border);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
}
.panel h3 {
margin-top: 0;
font-size: 14px;
font-weight: 600;
color: var(--theme-text-primary);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
/* Tables & Lists */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th {
text-align: left;
color: var(--theme-text-tertiary);
font-weight: 500;
padding: 12px 16px;
border-bottom: 1px solid var(--theme-border);
}
.data-table td {
padding: 12px 16px;
border-bottom: 1px solid rgba(114, 124, 139, 0.08);
color: var(--theme-text-secondary);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--theme-hover); }
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
color: var(--theme-accent);
font-size: 0.9em;
}
/* Analysis Summary */
.analysis-summary {
margin-bottom: 24px;
}
.analysis-summary h3 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
}
.summary-stat {
background: var(--theme-bg-surface);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
padding: 16px;
text-align: center;
}
.stat-value {
display: block;
font-size: 28px;
font-weight: 600;
color: var(--theme-accent);
margin-bottom: 4px;
}
.stat-label {
display: block;
font-size: 12px;
color: var(--theme-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Command Coverage Summary */
.coverage-summary {
padding: 12px 16px;
background: var(--theme-bg-surface-elevated);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
margin-bottom: 16px;
font-size: 13px;
}
.text-warning {
color: #e67e22;
}
.text-muted {
color: var(--theme-text-tertiary);
}
/* AI Insights */
.insight-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.insight-item {
padding: 16px;
border-radius: var(--radius-md);
background: var(--theme-hover);
border: 1px solid var(--theme-border);
display: flex;
gap: 12px;
}
.insight-icon { flex-shrink: 0; margin-top: 2px; }
.insight-content strong { display: block; margin-bottom: 4px; color: var(--theme-text-primary); }
.insight-content p { margin: 0; color: var(--theme-text-secondary); line-height: 1.5; }
/* Graph */
.graph-wrapper {
width: 100%;
height: calc(100vh - var(--header-height) - 64px);
background: var(--theme-bg-deep);
border-radius: var(--radius-lg);
border: 1px solid var(--theme-border);
overflow: hidden;
position: relative;
}
#cy { width: 100%; height: 100%; }
/* Scrollbar - theme-aware */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--theme-scrollbar, rgba(114, 124, 139, 0.2)); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--theme-scrollbar-hover, rgba(114, 124, 139, 0.4)); }
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--theme-scrollbar, rgba(114, 124, 139, 0.2)) transparent;
}
/* Footer */
.app-footer {
margin-top: auto;
padding: 24px 16px;
text-align: center;
color: var(--theme-text-tertiary);
font-size: 11px;
border-top: 1px solid var(--theme-border);
}
/* ============================================
Section & Tab Visibility (CRITICAL)
============================================ */
/* Section views - only show active */
.section-view {
display: none;
height: 100%;
flex-direction: column;
}
.section-view.active {
display: flex;
}
/* Tab panels - only show active */
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tab bar alias for JS selector */
.tab-bar {
/* Inherits from .header-tabs */
}
/* ============================================
Graph Container & Toolbars
============================================ */
/* ============================================
Graph Split Layout (Side-by-Side)
============================================ */
.graph-split-container {
display: flex;
height: calc(100vh - var(--header-height) - 32px);
gap: 0;
position: relative;
}
.graph-left-panel {
width: 380px;
min-width: 280px;
max-width: 600px;
display: flex;
flex-direction: column;
background: var(--theme-bg-surface);
border-right: 1px solid var(--theme-border);
overflow: hidden;
}
.graph-left-panel .component-panel {
flex: 1;
overflow-y: auto;
margin: 0;
border: none;
border-radius: 0;
}
.graph-left-panel .component-panel-header {
position: sticky;
top: 0;
z-index: 5;
padding: 8px 10px;
font-size: 11px;
}
/* Compact table for left panel */
.graph-left-panel .component-panel table {
font-size: 11px;
}
.graph-left-panel .component-panel th {
padding: 6px 8px;
font-size: 10px;
}
.graph-left-panel .component-panel td {
padding: 4px 8px;
}
.graph-left-panel .component-panel code {
font-size: 10px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.graph-left-panel .component-toolbar {
padding: 6px 10px;
font-size: 11px;
flex-wrap: wrap;
gap: 6px;
}
.graph-left-panel .component-toolbar label {
font-size: 10px;
}
.graph-left-panel .component-toolbar button {
padding: 3px 6px;
font-size: 10px;
}
.graph-left-panel .panel-actions {
gap: 6px;
}
.graph-left-panel .panel-actions label {
font-size: 10px;
}
.graph-left-panel .panel-actions input {
padding: 2px 4px;
width: 50px !important;
}
.graph-right-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 400px;
overflow: hidden;
}
.graph-right-panel .graph-toolbar {
flex-shrink: 0;
margin: 0;
border-radius: 0;
border-left: none;
border-right: none;
}
.graph-right-panel .graph {
flex: 1;
min-height: 0;
border-radius: 0;
border: none;
border-top: 1px solid var(--theme-border);
}
/* Resize handle */
.graph-resize-handle {
width: 6px;
cursor: col-resize;
background: var(--theme-border);
transition: background 0.15s;
flex-shrink: 0;
}
.graph-resize-handle:hover,
.graph-resize-handle.active {
background: var(--theme-accent);
}
/* Graph container - fallback for non-split */
.graph {
width: 100%;
height: calc(100vh - var(--header-height) - 200px);
min-height: 400px;
background: var(--theme-bg-deep);
border-radius: var(--radius-md);
border: 1px solid var(--theme-border);
}
.graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--theme-text-tertiary);
font-style: italic;
background: var(--theme-bg-surface);
border-radius: var(--radius-md);
border: 1px dashed var(--theme-border);
}
/* Graph toolbars */
.graph-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--theme-bg-surface);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
margin-bottom: 12px;
font-size: 12px;
}
.graph-toolbar label {
display: flex;
align-items: center;
gap: 6px;
color: var(--theme-text-secondary);
}
.graph-toolbar input[type="text"],
.graph-toolbar input[type="number"],
.graph-toolbar select {
background: var(--theme-bg-deep);
border: 1px solid var(--theme-border);
border-radius: var(--radius-sm);
padding: 4px 8px;
color: var(--theme-text-primary);
font-size: 12px;
font-family: var(--font-mono);
}
.graph-toolbar input[type="checkbox"] {
accent-color: var(--theme-accent);
}
.graph-toolbar button {
background: var(--theme-bg-surface-elevated);
border: 1px solid var(--theme-border);
border-radius: var(--radius-sm);
padding: 4px 10px;
color: var(--theme-text-secondary);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
}
.graph-toolbar button:hover {
background: rgba(163, 184, 199, 0.1);
color: var(--theme-text-primary);
border-color: var(--theme-accent);
}
.component-toolbar {
background: var(--theme-bg-surface-elevated);
}
.graph-controls {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-left: auto;
}
/* Graph legend */
.graph-legend {
display: flex;
gap: 16px;
padding: 8px 0;
font-size: 11px;
color: var(--theme-text-tertiary);
}
.graph-legend span {
display: flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
/* Graph hint */
.graph-hint {
padding: 12px 16px;
background: rgba(163, 184, 199, 0.05);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--theme-text-tertiary);
margin-top: 12px;
}
/* ============================================
Component Panel (Disconnected Components)
============================================ */
.component-panel {
background: var(--theme-bg-surface);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
margin-bottom: 12px;
overflow: hidden;
}
.component-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--theme-bg-surface-elevated);
border-bottom: 1px solid var(--theme-border);
font-size: 13px;
}
.component-panel-header strong {
color: var(--theme-text-primary);
}
.panel-actions {
display: flex;
align-items: center;
gap: 12px;
}
.panel-actions label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--theme-text-secondary);
}
.panel-actions input {
background: var(--theme-bg-deep);
border: 1px solid var(--theme-border);
border-radius: var(--radius-sm);
padding: 4px 8px;
color: var(--theme-text-primary);
font-size: 12px;
}
.panel-actions button {
background: var(--theme-bg-deep);
border: 1px solid var(--theme-border);
border-radius: var(--radius-sm);
padding: 4px 10px;
color: var(--theme-text-secondary);
font-size: 11px;
cursor: pointer;
}
.panel-actions button:hover {
border-color: var(--theme-accent);
color: var(--theme-text-primary);
}
.component-panel table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.component-panel th {
text-align: left;
padding: 10px 16px;
color: var(--theme-text-tertiary);
font-weight: 500;
border-bottom: 1px solid var(--theme-border);
background: var(--theme-bg-surface);
}
.component-panel td {
padding: 10px 16px;
color: var(--theme-text-secondary);
border-bottom: 1px solid rgba(114, 124, 139, 0.08);
}
.component-panel tr:hover td {
background: var(--theme-hover);
}
.component-panel button {
background: transparent;
border: 1px solid var(--theme-border);
border-radius: var(--radius-sm);
padding: 3px 8px;
color: var(--theme-text-tertiary);
font-size: 10px;
cursor: pointer;
}
.component-panel button:hover {
border-color: var(--theme-accent);
color: var(--theme-accent);
}
/* ============================================
Tauri Command Tables
============================================ */
.command-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-top: 16px;
}
.command-table th {
text-align: left;
padding: 12px 16px;
color: var(--theme-text-tertiary);
font-weight: 500;
border-bottom: 1px solid var(--theme-border);
}
.command-table td {
padding: 12px 16px;
border-bottom: 1px solid rgba(114, 124, 139, 0.08);
color: var(--theme-text-secondary);
}
.command-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
background: rgba(163, 184, 199, 0.1);
color: var(--theme-accent);
}
/* Module grouping */
.module-group {
margin-bottom: 24px;
}
.module-header {
font-size: 13px;
font-weight: 500;
color: var(--theme-text-secondary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--theme-border);
}
/* FE↔BE Bridge Comparison Table */
.bridge-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 16px;
background: var(--theme-bg-surface);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.bridge-table thead th {
text-align: left;
padding: 10px 14px;
color: var(--theme-text-tertiary);
font-weight: 500;
background: var(--theme-bg-surface-elevated);
border-bottom: 1px solid var(--theme-border);
}
.bridge-table tbody td {
padding: 8px 14px;
border-bottom: 1px solid rgba(114, 124, 139, 0.08);
color: var(--theme-text-secondary);
vertical-align: top;
}
.bridge-table tbody tr:last-child td {
border-bottom: none;
}
.bridge-table tbody tr:hover td {
background: var(--theme-hover);
}
.bridge-table .status-cell {
font-weight: 500;
white-space: nowrap;
}
.bridge-table .loc-cell {
font-family: var(--font-mono);
font-size: 11px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.bridge-table .loc-cell a {
color: var(--theme-accent);
}
.bridge-table .loc-cell a:hover {
text-decoration: underline;
}
/* Bridge row status colors */
.bridge-table tr.status-ok .status-cell {
color: #27ae60;
}
.bridge-table tr.status-missing .status-cell {
color: #e67e22;
}
.bridge-table tr.status-unused .status-cell {
color: var(--theme-text-tertiary);
}
.bridge-table tr.status-unregistered .status-cell {
color: #c0392b;
}
.bridge-table tr.status-missing {
background: rgba(230, 126, 34, 0.05);
}
.bridge-table tr.status-unregistered {
background: rgba(192, 57, 43, 0.05);
}
/* Gap details toggle */
.gap-details {
margin-top: 24px;
}
.gap-details summary {
cursor: pointer;
color: var(--theme-text-tertiary);
font-size: 12px;
padding: 8px 0;
}
.gap-details summary:hover {
color: var(--theme-text-secondary);
}
/* Text success color */
.text-success {
color: #27ae60;
}
/* ============================================
Utility Classes
============================================ */
.muted {
color: var(--theme-text-tertiary);
}
.icon-sm {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Range slider styling */
input[type="range"] {
-webkit-appearance: none;
background: var(--theme-bg-deep);
border-radius: 4px;
height: 6px;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: var(--theme-accent);
border-radius: 50%;
cursor: pointer;
}
/* ============================================
Quick Commands Panel (v0.6 features)
============================================ */
.quick-commands-panel {
background: var(--theme-bg-surface);
border: 1px solid var(--theme-border);
border-radius: var(--radius-lg);
padding: 20px 24px;
margin-top: 8px;
}
.quick-commands-panel h3 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 16px 0;
font-size: 14px;
font-weight: 600;
color: var(--theme-text-primary);
}
.badge-new {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background: linear-gradient(135deg, rgba(163, 184, 199, 0.2) 0%, rgba(79, 129, 225, 0.2) 100%);
color: var(--theme-accent);
padding: 3px 8px;
border-radius: 6px;
border: 1px solid rgba(163, 184, 199, 0.3);
}
.commands-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.command-group {
background: var(--theme-bg-surface-elevated);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
padding: 16px;
}
.command-group.highlight {
border-color: rgba(163, 184, 199, 0.3);
background: linear-gradient(135deg, var(--theme-bg-surface-elevated) 0%, rgba(163, 184, 199, 0.05) 100%);
}
.command-group h4 {
margin: 0 0 6px 0;
font-size: 13px;
font-weight: 600;
color: var(--theme-text-primary);
}
.command-group .command-desc {
margin: 0 0 12px 0;
font-size: 11px;
color: var(--theme-text-tertiary);
}
.command-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.command-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--theme-bg-deep);
border: 1px solid var(--theme-border);
border-radius: var(--radius-sm);
font-size: 11px;
}
.command-item:hover {
border-color: var(--theme-border-strong);
}
.command-code {
flex: 1;
font-family: var(--font-mono);
font-size: 11px;
color: var(--theme-accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: transparent;
padding: 0;
}
.command-desc-inline {
font-size: 10px;
color: var(--theme-text-tertiary);
white-space: nowrap;
}
.copy-btn {
flex-shrink: 0;
background: transparent;
border: none;
padding: 2px 4px;
cursor: pointer;
font-size: 12px;
opacity: 0.6;
transition: opacity 0.15s;
}
.copy-btn:hover {
opacity: 1;
}
.commands-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--theme-border);
}
.commands-footer p {
margin: 0;
font-size: 11px;
color: var(--theme-text-tertiary);
}
.commands-footer code {
font-size: 10px;
background: var(--theme-bg-deep);
padding: 2px 6px;
border-radius: 4px;
color: var(--theme-accent);
}
/* ============================================
Tree Component Styles
============================================ */
.tree-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.tree-header {
display: flex;
align-items: center;
gap: 12px;
}
.tree-header h3 {
margin: 0;
white-space: nowrap;
}
.tree-controls {
display: flex;
gap: 4px;
}
.tree-btn {
padding: 6px 10px;
border: 1px solid var(--theme-border);
border-radius: 6px;
background: var(--theme-surface);
color: var(--theme-text);
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
}
.tree-btn:hover {
background: var(--theme-bg-surface-elevated);
border-color: var(--theme-border-strong);
}
.tree-filter {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--theme-border);
border-radius: 8px;
background: var(--theme-surface);
color: var(--theme-text);
font-size: 13px;
}
.tree-filter:focus {
outline: none;
border-color: var(--theme-accent);
}
.tree-container {
max-height: 600px;
overflow-y: auto;
padding-right: 8px;
}
.tree-node {
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
font-size: 12px;
}
.tree-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
transition: background 0.1s ease;
}
.tree-row:hover {
background: var(--theme-bg-surface-elevated);
}
.tree-row-dir {
cursor: pointer;
}
.tree-row-dir:hover {
background: rgba(var(--theme-accent-rgb), 0.1);
}
.tree-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
}
.tree-connector {
color: var(--theme-text-tertiary);
white-space: pre;
flex-shrink: 0;
}
.tree-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: var(--theme-text-secondary);
transition: transform 0.15s ease;
flex-shrink: 0;
}
.tree-chevron.collapsed {
transform: rotate(0deg);
}
.tree-chevron:not(.collapsed) {
transform: rotate(90deg);
}
.tree-icon {
flex-shrink: 0;
font-size: 14px;
}
.tree-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--theme-text);
}
.tree-highlight {
background: rgba(255, 200, 0, 0.3);
color: inherit;
padding: 0 2px;
border-radius: 2px;
}
.tree-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: 12px;
}
.tree-loc-bar {
width: 60px;
height: 4px;
background: var(--theme-border);
border-radius: 2px;
overflow: hidden;
}
.tree-loc-fill {
height: 100%;
background: var(--theme-accent);
border-radius: 2px;
transition: width 0.2s ease;
}
.tree-loc {
color: var(--theme-text-tertiary);
font-size: 11px;
min-width: 60px;
text-align: right;
}
.tree-children {
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.15s ease;
}
.tree-children.collapsed {
max-height: 0 !important;
opacity: 0;
pointer-events: none;
}
/* ============================================
Crowds Component Styles
============================================ */
.crowds-list {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 16px;
}
.crowd-card {
background: var(--theme-bg-surface-elevated);
border: 1px solid var(--theme-border);
border-radius: var(--radius-lg);
padding: 20px;
transition: border-color 0.2s ease;
}
.crowd-card:hover {
border-color: var(--theme-border-strong);
}
.crowd-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--theme-border);
}
.crowd-pattern {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.crowd-pattern code {
font-size: 14px;
font-weight: 600;
color: var(--theme-accent);
background: rgba(163, 184, 199, 0.1);
padding: 6px 12px;
border-radius: var(--radius-md);
}
.crowd-member-count {
font-size: 12px;
}
.crowd-score {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 16px;
background: var(--theme-bg-deep);
border-radius: var(--radius-md);
border: 2px solid var(--score-color, var(--theme-border));
min-width: 80px;
}
.score-value {
font-size: 24px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--score-color, var(--theme-text-primary));
line-height: 1;
}
.score-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-text-tertiary);
margin-top: 4px;
}
.crowd-issues {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.issue-badge {
display: inline-block;
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
border: 1px solid;
}
.issue-critical {
background: rgba(192, 57, 43, 0.1);
border-color: rgba(192, 57, 43, 0.3);
color: #c0392b;
}
.issue-warning {
background: rgba(230, 126, 34, 0.1);
border-color: rgba(230, 126, 34, 0.3);
color: #e67e22;
}
.issue-info {
background: rgba(49, 130, 206, 0.1);
border-color: rgba(49, 130, 206, 0.3);
color: #3182ce;
}
.crowd-members {
margin-top: 12px;
}
.crowd-members .data-table {
font-size: 12px;
}
.crowd-members .data-table th {
padding: 8px 12px;
font-size: 11px;
}
.crowd-members .data-table td {
padding: 8px 12px;
}
.file-path {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
/* ============================================
Dead Code Component Styles
============================================ */
.dead-code-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--theme-bg-surface-elevated);
border: 1px solid var(--theme-border);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--theme-text-secondary);
cursor: pointer;
}
.filter-toggle input[type="checkbox"] {
accent-color: var(--theme-accent);
cursor: pointer;
}
.dead-exports-table {
font-size: 13px;
}
.dead-exports-table .file-cell code,
.dead-exports-table .symbol-cell code {
font-family: var(--font-mono);
font-size: 12px;
}
.dead-exports-table .file-cell a {
color: var(--theme-accent);
text-decoration: none;
}
.dead-exports-table .file-cell a:hover {
text-decoration: underline;
}
.dead-exports-table .line-cell {
font-family: var(--font-mono);
font-size: 11px;
text-align: center;
color: var(--theme-text-tertiary);
}
.dead-exports-table .confidence-cell {
text-align: center;
}
.confidence-badge {
display: inline-block;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.confidence-badge.confidence-very-high {
background: rgba(192, 57, 43, 0.15);
color: #c0392b;
border: 1px solid rgba(192, 57, 43, 0.3);
}
.confidence-badge.confidence-high {
background: rgba(230, 126, 34, 0.15);
color: #e67e22;
border: 1px solid rgba(230, 126, 34, 0.3);
}
.confidence-badge.confidence-medium {
background: rgba(49, 130, 206, 0.15);
color: #3182ce;
border: 1px solid rgba(49, 130, 206, 0.3);
}
.dead-exports-table .reason-cell {
font-size: 12px;
max-width: 300px;
color: var(--theme-text-secondary);
}
.dead-exports-table tr.confidence-very-high {
background: rgba(192, 57, 43, 0.03);
}
.dead-exports-table tr.confidence-high {
background: rgba(230, 126, 34, 0.03);
}
.dead-exports-table tr:hover {
background: var(--theme-hover-strong) !important;
}
/* ============================================
Cycles Component
============================================ */
/* Count badges */
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 20px;
padding: 0 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
margin-left: auto;
}
.count-badge-success {
background: rgba(39, 174, 96, 0.15);
color: #27ae60;
border: 1px solid rgba(39, 174, 96, 0.3);
}
.count-badge-warning {
background: rgba(230, 126, 34, 0.15);
color: #e67e22;
border: 1px solid rgba(230, 126, 34, 0.3);
}
.count-badge-critical {
background: rgba(192, 57, 43, 0.15);
color: #c0392b;
border: 1px solid rgba(192, 57, 43, 0.3);
}
/* Empty state */
.cycles-empty {
padding: 32px;
text-align: center;
background: rgba(39, 174, 96, 0.05);
border-radius: var(--radius-md);
border: 1px dashed rgba(39, 174, 96, 0.3);
}
.cycles-empty p {
color: #27ae60;
font-size: 13px;
margin: 0;
}
/* Cycles section */
.cycles-section {
margin-bottom: 24px;
padding: 20px;
border-radius: var(--radius-md);
border: 1px solid var(--theme-border);
}
.cycles-section-strict {
background: rgba(192, 57, 43, 0.05);
border-color: rgba(192, 57, 43, 0.2);
}
.cycles-section-lazy {
background: rgba(230, 126, 34, 0.05);
border-color: rgba(230, 126, 34, 0.2);
}
.cycles-section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.cycles-section-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--theme-text-primary);
}
.cycles-section-desc {
font-size: 12px;
color: var(--theme-text-secondary);
margin: 0 0 16px 0;
padding-left: 30px;
}
/* Cycles list */
.cycles-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Individual cycle item */
.cycle-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--theme-bg-surface);
border-radius: var(--radius-md);
border: 1px solid var(--theme-border);
}
.cycle-item-strict {
border-left: 3px solid #c0392b;
}
.cycle-item-lazy {
border-left: 3px solid #e67e22;
}
.cycle-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 24px;
padding: 0 8px;
background: var(--theme-hover);
border: 1px solid var(--theme-border);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--theme-text-tertiary);
flex-shrink: 0;
}
.cycle-path {
flex: 1;
font-family: var(--font-mono);
font-size: 12px;
color: var(--theme-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(0, 0, 0, 0.2);
padding: 4px 8px;
border-radius: 4px;
}
/* ============================================
Responsive
============================================ */
@media (max-width: 900px) {
.app-shell {
flex-direction: column;
}
.app-sidebar {
width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--theme-border);
}
.sidebar-nav {
flex-direction: row;
overflow-x: auto;
padding: 12px;
}
.nav-section-title {
display: none;
}
.app-footer {
display: none;
}
.header-tabs {
flex-wrap: wrap;
}
.graph-toolbar {
flex-direction: column;
align-items: stretch;
}
.graph-controls {
margin-left: 0;
justify-content: center;
}
}
</style></head><body><div class="app-shell"><aside class="app-sidebar"><div class="sidebar-header"><div class="logo-box"><img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYwIiBoZWlnaHQ9IjM2MCIgdmlld0JveD0iMCAwIDM2MCAzNjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsbGVkYnk9InRpdGxlIGRlc2MiPgogIDx0aXRsZSBpZD0idGl0bGUiPkxvY3RyZWUgTG9nbzwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2MiPk1pbmltYWxpc3Qgbm9kZSB0cmVlIC0gZHluYW1pYyBhbmQgc2xpZ2h0bHkgdW5zZXR0bGluZzwvZGVzYz4KCiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5ub2RlIHsgZmlsbDogI2UwZTBlMDsgfQogICAgICAuc3RlbSB7IHN0cm9rZTogI2UwZTBlMDsgc3Ryb2tlLXdpZHRoOiAxMDsgc3Ryb2tlLWxpbmVjYXA6IHJvdW5kOyB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KCiAgPCEtLSBSb3cgMSAtIDMgbm9kZXMgKHRvcCwgc3ltbWV0cmljLCB0aWdodGVuZWQgMjBweCkgLS0+CiAgPGNpcmNsZSBjbGFzcz0ibm9kZSIgY3g9Ijc1IiBjeT0iNTAiIHI9IjE2Ii8+CiAgPGNpcmNsZSBjbGFzcz0ibm9kZSIgY3g9IjE4MCIgY3k9IjUwIiByPSIxNiIvPgogIDxjaXJjbGUgY2xhc3M9Im5vZGUiIGN4PSIyODUiIGN5PSI1MCIgcj0iMTYiLz4KCiAgPCEtLSBSb3cgMiAtIDEgbm9kZSAodW5zZXR0bGluZ2x5IG9mZi1jZW50ZXIgdG8gbGVmdCkgLS0+CiAgPGNpcmNsZSBjbGFzcz0ibm9kZSIgY3g9IjE0MCIgY3k9IjEyMCIgcj0iMTYiLz4KCiAgPCEtLSBSb3cgMyAtIDMgbm9kZXMgKHN5bW1ldHJpYywgdGlnaHRlbmVkIDIwcHgpIC0tPgogIDxjaXJjbGUgY2xhc3M9Im5vZGUiIGN4PSI3NSIgY3k9IjE5MCIgcj0iMTYiLz4KICA8Y2lyY2xlIGNsYXNzPSJub2RlIiBjeD0iMTgwIiBjeT0iMTkwIiByPSIxNiIvPgogIDxjaXJjbGUgY2xhc3M9Im5vZGUiIGN4PSIyODUiIGN5PSIxOTAiIHI9IjE2Ii8+CgogIDwhLS0gU3RlbSAodmVydGljYWwsIHNoaWZ0ZWQgcmlnaHQsIHRoaWNrZXIpIC0tPgogIDxsaW5lIGNsYXNzPSJzdGVtIiB4MT0iMjEwIiB5MT0iMjIwIiB4Mj0iMjEwIiB5Mj0iMjc1Ii8+CgogIDwhLS0gUm9vdCBub2RlIGF0IGJvdHRvbSAtLT4KICA8Y2lyY2xlIGNsYXNzPSJub2RlIiBjeD0iMjA1IiBjeT0iMzEwIiByPSIxNiIvPgo8L3N2Zz4K" alt="loctree logo" class="logo-img"><div class="logo-text"><span style="color:var(--theme-accent)">Loctree</span><span style="opacity:0.5">Report</span></div></div><button class="theme-toggle" data-role="theme-toggle" title="Toggle light/dark mode"><svg class="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 256 256"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"></path></svg><svg class="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 256 256"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"></path></svg></button></div><nav class="sidebar-nav"><button data-tab="overview" class="nav-item active"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M104,48H48A16,16,0,0,0,32,64v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V64A16,16,0,0,0,104,48Zm0,72H48V64h56Zm104-72H152a16,16,0,0,0-16,16v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V64A16,16,0,0,0,208,48Zm0,72H152V64h56ZM104,152H48a16,16,0,0,0-16,16v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V168A16,16,0,0,0,104,152Zm0,72H48V168h56Zm104-72H152a16,16,0,0,0-16,16v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V168A16,16,0,0,0,208,152Zm0,72H152V168h56Z"></path></svg>Overview</button><button data-tab="dups" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>Duplicates</button><button data-tab="dynamic" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7L37.71,143.17A8,8,0,0,0,44.22,156l57.6,11.52L87.16,240.83A8,8,0,0,0,95,248a7.72,7.72,0,0,0,1.57-.16l116.67-46.67a8,8,0,0,0,2.55-14.5ZM96.82,224,116,128a8,8,0,0,0-6.51-9.54L52.22,107,159.18,32,140,128a8,8,0,0,0,6.51,9.54l57.27,11.45Z"></path></svg>Dynamic imports</button> <button data-tab="crowds" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M117.25,157.92a60,60,0,1,0-66.5,0A95.83,95.83,0,0,0,3.53,195.63a8,8,0,1,0,13.4,8.74,80,80,0,0,1,134.14,0,8,8,0,0,0,13.4-8.74A95.83,95.83,0,0,0,117.25,157.92ZM40,108a44,44,0,1,1,44,44A44.05,44.05,0,0,1,40,108Zm210.14,98.7a8,8,0,0,1-11.07-2.33A79.83,79.83,0,0,0,172,168a8,8,0,0,1,0-16,44,44,0,1,0-16.34-84.87,8,8,0,1,1-5.94-14.85,60,60,0,0,1,55.53,105.64,95.83,95.83,0,0,1,47.22,37.71A8,8,0,0,1,250.14,206.7Z"></path></svg>Crowds</button><button data-tab="cycles" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M224,48V96a8,8,0,0,1-8,8H168a8,8,0,0,1,0-16h28.69L182.06,73.37a79.56,79.56,0,0,0-56.13-23.43h-.45A79.52,79.52,0,0,0,69.59,72.71,8,8,0,0,1,58.41,61.27a96,96,0,0,1,135,.79L208,76.69V48a8,8,0,0,1,16,0ZM186.41,183.29a80,80,0,0,1-112.47-.66L59.31,168H88a8,8,0,0,0,0-16H40a8,8,0,0,0-8,8v48a8,8,0,0,0,16,0V179.31l14.63,14.63A95.43,95.43,0,0,0,130,222.06h.53a95.36,95.36,0,0,0,67.07-27.33,8,8,0,0,0-11.18-11.44Z"></path></svg>Cycles</button><button data-tab="dead" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M112,120a12,12,0,1,1,12-12A12,12,0,0,1,112,120Zm44-12a12,12,0,1,0,12,12A12,12,0,0,0,156,108Zm68,12a104,104,0,0,1-208,0c0-34,17.33-69.36,46.77-94.35A103.36,103.36,0,0,1,128,8a104.11,104.11,0,0,1,96,112Zm-16,0a88.11,88.11,0,0,0-85.33-88h0C136,32.58,104,54.37,77.07,86.76,49.55,117.8,32,154.29,32,192a88,88,0,0,0,176,0Zm-16,72a8,8,0,0,0,0-16H188l-3.57-4.76a8,8,0,0,0-12.86,0L168,156l-3.57-4.76a8,8,0,0,0-12.86,0L148,156l-3.57-4.76a8,8,0,0,0-12.86,0L128,156l-3.57-4.76a8,8,0,0,0-12.86,0L108,156l-3.57-4.76a8,8,0,0,0-12.86,0L88,156l-3.57-4.76a8,8,0,0,0-12.86,0L68,156a8,8,0,0,0,0,16Z"></path></svg>Dead Code</button><button data-tab="twins" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>Twins</button><button data-tab="coverage" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M223.59,199.73,160,93.78V32h8a8,8,0,0,0,0-16H88a8,8,0,0,0,0,16h8V93.78L32.41,199.73A16,16,0,0,0,46.41,224H209.59a16,16,0,0,0,14-24.27ZM109.59,105.42l6.41-11.08V32h24V94.34l6.41,11.08L128,133.08ZM46.41,208l54.09-93.44L128,159.57l27.5-45L209.59,208Z"></path></svg>Coverage</button><button data-tab="graph" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M208,152a32.06,32.06,0,0,0-25.87,13.26l-52.3-29.06a32,32,0,0,0,0-16.4l52.3-29.06A32.06,32.06,0,0,0,208,104a32,32,0,1,0-31.71-28.29L124,104.78a32,32,0,1,0,0,46.44l52.3,29.06A32,32,0,1,0,208,152ZM208,56a16,16,0,1,1-16,16A16,16,0,0,1,208,56ZM80,128a16,16,0,1,1,16,16A16,16,0,0,1,80,128Zm128,88a16,16,0,1,1,16-16A16,16,0,0,1,208,216Z"></path></svg>Graph</button><button data-tab="tree" class="nav-item"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M160,112h48a16,16,0,0,0,16-16V48a16,16,0,0,0-16-16H160a16,16,0,0,0-16,16V64H128a24,24,0,0,0-24,24v32H72v-8A16,16,0,0,0,56,96H24A16,16,0,0,0,8,112v32a16,16,0,0,0,16,16H56a16,16,0,0,0,16-16v-8h32v32a24,24,0,0,0,24,24h16v16a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V160a16,16,0,0,0-16-16H160a16,16,0,0,0-16,16v16H128a8,8,0,0,1-8-8V88a8,8,0,0,1,8-8h16V96A16,16,0,0,0,160,112ZM56,144H24V112H56v32Zm104,16h48v48H160Zm0-112h48V96H160Z"></path></svg>Tree</button></nav><div class="app-footer"><button id="toggle-tests-btn" title="Toggle test file visibility" class="test-toggle-btn"><span id="test-toggle-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M223.59,199.73,160,93.78V32h8a8,8,0,0,0,0-16H88a8,8,0,0,0,0,16h8V93.78L32.41,199.73A16,16,0,0,0,46.41,224H209.59a16,16,0,0,0,14-24.27ZM109.59,105.42l6.41-11.08V32h24V94.34l6.41,11.08L128,133.08ZM46.41,208l54.09-93.44L128,159.57l27.5-45L209.59,208Z"></path></svg></span><span id="test-toggle-text">Hide Tests</span></button><div style="margin-top: 8px; font-size: 11px;">loctree v0.6.11<br><span style="color:var(--theme-text-tertiary)">Snapshot</span></div></div></aside><main class="app-main"><div id="section-view-0" class="section-view active"><header class="app-header"><div class="header-title"><h1>tests/fixtures/simple_ts</h1><p title="/Users/maciejgad/hosted/loctree/loctree_rs/tests/fixtures/simple_ts" class="header-path">/Users/maciejgad/hosted/loctree/loctree_rs/tests/fixtures/simple_ts</p><p title="git branch @ commit" class="header-path" style="margin-top:4px;color:var(--theme-text-tertiary);">develop@7d599b92</p></div><div class="header-stats"><span class="stat-badge"><span class="stat-badge-value">3</span><span class="stat-badge-label">files</span></span><span class="stat-badge"><span class="stat-badge-value">26</span><span class="stat-badge-label">LOC</span></span><span class="stat-badge"><span class="stat-badge-value">0</span><span class="stat-badge-label">dups</span></span></div></header><div class="app-content"><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="overview" class="tab-panel active"><div class="content-container"><div class="analysis-summary"><h3><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M104,48H48A16,16,0,0,0,32,64v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V64A16,16,0,0,0,104,48Zm0,72H48V64h56Zm104-72H152a16,16,0,0,0-16,16v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V64A16,16,0,0,0,208,48Zm0,72H152V64h56ZM104,152H48a16,16,0,0,0-16,16v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V168A16,16,0,0,0,104,152Zm0,72H48V168h56Zm104-72H152a16,16,0,0,0-16,16v56a16,16,0,0,0,16,16h56a16,16,0,0,0,16-16V168A16,16,0,0,0,208,152Zm0,72H152V168h56Z"></path></svg>Analysis Summary</h3><div class="summary-grid"><div class="summary-stat"><span class="stat-value">3</span><span class="stat-label">Files analyzed</span></div><div class="summary-stat"><span class="stat-value">26</span><span class="stat-label">Total LOC</span></div><div class="summary-stat"><span class="stat-value">0</span><span class="stat-label">Duplicate exports</span></div><div class="summary-stat"><span class="stat-value">0</span><span class="stat-label">Re-export files</span></div><div class="summary-stat"><span class="stat-value">0</span><span class="stat-label">Dynamic imports</span></div></div></div><!><!--<() />--><div class="quick-commands-panel"><h3><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M216,48H40A16,16,0,0,0,24,64V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V64A16,16,0,0,0,216,48ZM40,64H216V192H40V64Zm84,84H92a8,8,0,0,1-5.66-13.66l32-32a8,8,0,0,1,11.32,11.32L103.31,140l26.35,26.34A8,8,0,0,1,124,148Zm92,0H152a8,8,0,0,1,0-16h64a8,8,0,0,1,0,16Z"></path></svg>Quick Commands<span class="badge-new">v0.6</span></h3><div class="commands-grid"><div class="command-group"><h4>Query API</h4><p class="command-desc">Fast graph queries without full analysis</p><div class="command-list"><div class="command-item"><code class="command-code">loct query who-imports /Users/maciejgad/hosted/loctree/loctree_rs/tests/fixtures/simple_ts/src/main.ts</code><span class="command-desc-inline">Files that import target</span><button data-copy="loct query who-imports /Users/maciejgad/hosted/loctree/loctree_rs/tests/fixtures/simple_ts/src/main.ts" title="Copy to clipboard" class="copy-btn">Copy</button></div><div class="command-item"><code class="command-code">loct query where-symbol useAuth</code><span class="command-desc-inline">Find symbol definitions</span><button data-copy="loct query where-symbol useAuth" title="Copy to clipboard" class="copy-btn">Copy</button></div><div class="command-item"><code class="command-code">loct query component-of /Users/maciejgad/hosted/loctree/loctree_rs/tests/fixtures/simple_ts/src/main.ts</code><span class="command-desc-inline">Graph component containing file</span><button data-copy="loct query component-of /Users/maciejgad/hosted/loctree/loctree_rs/tests/fixtures/simple_ts/src/main.ts" title="Copy to clipboard" class="copy-btn">Copy</button></div></div></div><div class="command-group"><h4>Snapshot Diff</h4><p class="command-desc">Compare snapshots to track changes</p><div class="command-list"><div class="command-item"><code class="command-code">loct diff --since main</code><span class="command-desc-inline">Compare against main branch</span><button data-copy="loct diff --since main" title="Copy to clipboard" class="copy-btn">Copy</button></div><div class="command-item"><code class="command-code">loct diff --since HEAD~5</code><span class="command-desc-inline">Delta since 5 commits ago</span><button data-copy="loct diff --since HEAD~5" title="Copy to clipboard" class="copy-btn">Copy</button></div></div></div><!><!></div><div class="commands-footer"><p><code>loctree://open?f=<file>&l=<line></code> - IDE integration URLs in SARIF output</p></div></div></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="dups" class="tab-panel"><div class="content-container"><h3>Top duplicate exports</h3><p class="muted">None</p><h3>Re-export cascades</h3><p class="muted">None</p></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="dynamic" class="tab-panel"><div class="content-container"><h3>Dynamic imports</h3><p class="muted">None</p></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="commands" class="tab-panel"><div class="content-container"><h3>Tauri command coverage</h3><p class="muted">No Tauri commands detected in this root.</p></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="crowds" class="tab-panel"><div class="content-container"><div class="panel"><h3>Crowds Analysis</h3><p class="muted">No crowds detected. Your codebase has well-distributed naming patterns.</p></div></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="cycles" class="tab-panel"><div class="content-container"><div class="panel"><h3><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#27ae60" viewBox="0 0 256 256" class=""><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,40a12,12,0,1,1,12-12A12,12,0,0,1,128,176Z"></path></svg>Circular Imports<span class="count-badge count-badge-success">0</span></h3><div class="cycles-empty"><p class="muted">No circular imports detected</p></div></div></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="dead" class="tab-panel"><div class="content-container"><div class="panel"><h3><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M112,120a12,12,0,1,1,12-12A12,12,0,0,1,112,120Zm44-12a12,12,0,1,0,12,12A12,12,0,0,0,156,108Zm68,12a104,104,0,0,1-208,0c0-34,17.33-69.36,46.77-94.35A103.36,103.36,0,0,1,128,8a104.11,104.11,0,0,1,96,112Zm-16,0a88.11,88.11,0,0,0-85.33-88h0C136,32.58,104,54.37,77.07,86.76,49.55,117.8,32,154.29,32,192a88,88,0,0,0,176,0Zm-16,72a8,8,0,0,0,0-16H188l-3.57-4.76a8,8,0,0,0-12.86,0L168,156l-3.57-4.76a8,8,0,0,0-12.86,0L148,156l-3.57-4.76a8,8,0,0,0-12.86,0L128,156l-3.57-4.76a8,8,0,0,0-12.86,0L108,156l-3.57-4.76a8,8,0,0,0-12.86,0L88,156l-3.57-4.76a8,8,0,0,0-12.86,0L68,156a8,8,0,0,0,0,16Z"></path></svg>Dead Exports</h3><div class="dead-code-summary"><p class="muted">2 dead exports found (2 very high confidence)</p><label class="filter-toggle"><input type="checkbox">Show very high confidence only</label></div><table class="data-table dead-exports-table"><thead><tr><th>File</th><th>Symbol</th><th>Line</th><th>Confidence</th><th>Reason</th></tr></thead><tbody><tr data-is-test="false" class="confidence-very-high"><td class="file-cell"><a href="loctree://open?f=src%2Futils%2Fdate.ts&l=6" title="Open in editor"><code>src/utils/date.ts</code></a></td><td class="symbol-cell"><code>parseDate</code></td><td class="line-cell">6</td><td class="confidence-cell"><span class="confidence-badge confidence-very-high">Very High</span></td><td class="reason-cell">No imports found for 'parseDate'. Checked: resolved imports (0 matches), star re-exports (none), local references (none)</td></tr><tr data-is-test="false" class="confidence-very-high"><td class="file-cell"><a href="loctree://open?f=src%2Futils%2Fgreeting.ts&l=6" title="Open in editor"><code>src/utils/greeting.ts</code></a></td><td class="symbol-cell"><code>farewell</code></td><td class="line-cell">6</td><td class="confidence-cell"><span class="confidence-badge confidence-very-high">Very High</span></td><td class="reason-cell">No imports found for 'farewell'. Checked: resolved imports (0 matches), star re-exports (none), local references (none)</td></tr><!></tbody></table></div></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="twins" class="tab-panel"><div class="content-container"><div class="panel"><h3><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>Twins Analysis</h3><div class="twins-summary" style="margin-bottom: 24px;;"><p class="muted">2 dead parrots, 0 exact twins, 0 barrel issues</p></div><div data-twins-section="dead-parrots" class="twins-section"><button data-toggle="twins-dead-parrots-content" class="twins-section-header"><span class="twins-section-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M112,120a12,12,0,1,1,12-12A12,12,0,0,1,112,120Zm44-12a12,12,0,1,0,12,12A12,12,0,0,0,156,108Zm68,12a104,104,0,0,1-208,0c0-34,17.33-69.36,46.77-94.35A103.36,103.36,0,0,1,128,8a104.11,104.11,0,0,1,96,112Zm-16,0a88.11,88.11,0,0,0-85.33-88h0C136,32.58,104,54.37,77.07,86.76,49.55,117.8,32,154.29,32,192a88,88,0,0,0,176,0Zm-16,72a8,8,0,0,0,0-16H188l-3.57-4.76a8,8,0,0,0-12.86,0L168,156l-3.57-4.76a8,8,0,0,0-12.86,0L148,156l-3.57-4.76a8,8,0,0,0-12.86,0L128,156l-3.57-4.76a8,8,0,0,0-12.86,0L108,156l-3.57-4.76a8,8,0,0,0-12.86,0L88,156l-3.57-4.76a8,8,0,0,0-12.86,0L68,156a8,8,0,0,0,0,16Z"></path></svg> Dead Parrots (2 symbols)</span><span class="twins-section-toggle">▶</span></button><div id="twins-dead-parrots-content" class="twins-section-content" style="display: none;;"><table class="data-table twins-table"><thead><tr><th>File</th><th>Symbol</th><th>Kind</th><th>Line</th></tr></thead><tbody><tr data-is-test="false"><td class="file-cell"><code>src/utils/date.ts</code></td><td class="symbol-cell"><code>parseDate</code></td><td class="kind-cell">function</td><td class="line-cell">6</td></tr><tr data-is-test="false"><td class="file-cell"><code>src/utils/greeting.ts</code></td><td class="symbol-cell"><code>farewell</code></td><td class="kind-cell">function</td><td class="line-cell">6</td></tr><!></tbody></table></div></div><div data-twins-section="exact" class="twins-section"><button data-toggle="twins-exact-content" class="twins-section-header"><span class="twins-section-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg> Exact Twins (0 duplicates)</span><span class="twins-section-toggle">▶</span></button><div id="twins-exact-content" class="twins-section-content" style="display: none;"><p class="muted">No exact twins found - all symbol names are unique!</p></div></div><div data-twins-section="barrel" class="twins-section"><button data-toggle="twins-barrel-content" class="twins-section-header"><span class="twins-section-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="icon-sm"><path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200h0a16,16,0,0,0,16,16H211.1a16,16,0,0,0,15.38-11.63l24.42-75.38A16,16,0,0,0,245,110.64ZM93.34,64l27.73,20.8a16.12,16.12,0,0,0,9.6,3.2H200v16H69.77a16,16,0,0,0-15.38,11.63L40,163.42V64Zm117.76,136H40l24.48-75.62H232Z"></path></svg> Barrel Chaos (0 issues)</span><span class="twins-section-toggle">▶</span></button><div id="twins-barrel-content" class="twins-section-content" style="display: none;"><p class="muted">No barrel chaos detected - clean module structure!</p></div></div></div></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="coverage" class="tab-panel"><div class="content-container"><div class="panel"><h3><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M223.59,199.73,160,93.78V32h8a8,8,0,0,0,0-16H88a8,8,0,0,0,0,16h8V93.78L32.41,199.73A16,16,0,0,0,46.41,224H209.59a16,16,0,0,0,14-24.27ZM109.59,105.42l6.41-11.08V32h24V94.34l6.41,11.08L128,133.08ZM46.41,208l54.09-93.44L128,159.57l27.5-45L209.59,208Z"></path></svg>Coverage Gaps</h3><div class="graph-empty"><div style="text-align: center; padding: 32px;;"><svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="var(--theme-text-tertiary)" viewBox="0 0 256 256" class=""><path d="M223.59,199.73,160,93.78V32h8a8,8,0,0,0,0-16H88a8,8,0,0,0,0,16h8V93.78L32.41,199.73A16,16,0,0,0,46.41,224H209.59a16,16,0,0,0,14-24.27ZM109.59,105.42l6.41-11.08V32h24V94.34l6.41,11.08L128,133.08ZM46.41,208l54.09-93.44L128,159.57l27.5-45L209.59,208Z"></path></svg><p style="margin-top: 16px; color: var(--theme-text-secondary);">No coverage gaps detected</p><p class="muted" style="font-size: 12px; margin-top: 8px;">All production code has test coverage</p></div></div></div></div></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="graph" class="tab-panel"><!><div id="graph-_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" class="graph"><div data-graph-id="graph-_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" class="graph-wasm-target"></div></div><script>window.__LOCTREE_GRAPHS = window.__LOCTREE_GRAPHS || [];
window.__LOCTREE_GRAPHS.push({
id: "graph-_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts",
label: "/Users/maciejgad/hosted/loctree/loctree_rs/tests/fixtures/simple_ts",
nodes: [{"id":"src/index.ts","label":"index.ts","loc":10,"x":199.96152,"y":12.0,"component":1,"degree":2,"detached":false},{"id":"src/utils/date.ts","label":"date.ts","loc":8,"x":-105.980774,"y":183.56406,"component":1,"degree":1,"detached":false},{"id":"src/utils/greeting.ts","label":"greeting.ts","loc":8,"x":-93.98074,"y":-195.56407,"component":1,"degree":1,"detached":false}],
edges: [["src/index.ts","src/utils/greeting.ts","import"],["src/index.ts","src/utils/date.ts","import"]],
components: [{"id":1,"size":3,"edges":2,"nodes":["src/index.ts","src/utils/date.ts","src/utils/greeting.ts"],"isolated_count":0,"sample":"src/index.ts","loc_sum":26,"detached":false,"tauri_frontend":0,"tauri_backend":0}],
mainComponent: 1,
openBase: null,
dot: "digraph loctree {\n // Graph attributes\n graph [rankdir=TB, splines=true, overlap=false, nodesep=0.5, ranksep=0.8];\n node [shape=box, style=\"rounded,filled\", fontname=\"sans-serif\", fontsize=10];\n edge [arrowsize=0.7, fontsize=8];\n\n subgraph cluster_1 {\n style=invis;\n label=\"Component 1\";\n \"src/index.ts\" [label=\"index.ts\\n(10 LOC)\", fillcolor=\"#4f81e1\", width=0.32, height=0.19];\n \"src/utils/date.ts\" [label=\"date.ts\\n(8 LOC)\", fillcolor=\"#4f81e1\", width=0.32, height=0.19];\n \"src/utils/greeting.ts\" [label=\"greeting.ts\\n(8 LOC)\", fillcolor=\"#4f81e1\", width=0.32, height=0.19];\n }\n\n // Edges\n \"src/index.ts\" -> \"src/utils/greeting.ts\" [color=\"#888888\"];\n \"src/index.ts\" -> \"src/utils/date.ts\" [color=\"#888888\"];\n}\n",
dotDark: "digraph loctree {\n graph [rankdir=TB, splines=true, overlap=false, nodesep=0.5, ranksep=0.8, bgcolor=\"#0f1115\"];\n node [shape=box, style=\"rounded,filled\", fontname=\"sans-serif\", fontsize=10, fontcolor=\"#eef2ff\"];\n edge [arrowsize=0.7, fontsize=8, fontcolor=\"#aaa\"];\n\n \"src/index.ts\" [label=\"index.ts\\n(10 LOC)\", fillcolor=\"#4f81e1\"];\n \"src/utils/date.ts\" [label=\"date.ts\\n(8 LOC)\", fillcolor=\"#4f81e1\"];\n \"src/utils/greeting.ts\" [label=\"greeting.ts\\n(8 LOC)\", fillcolor=\"#4f81e1\"];\n \"src/index.ts\" -> \"src/utils/greeting.ts\" [color=\"#666666\"];\n \"src/index.ts\" -> \"src/utils/date.ts\" [color=\"#666666\"];\n}\n",
graphJson: {"nodes":[{"id":"src/index.ts","label":"index.ts","loc":10,"x":199.96152,"y":12.0,"component":1,"degree":2,"detached":false},{"id":"src/utils/date.ts","label":"date.ts","loc":8,"x":-105.980774,"y":183.56406,"component":1,"degree":1,"detached":false},{"id":"src/utils/greeting.ts","label":"greeting.ts","loc":8,"x":-93.98074,"y":-195.56407,"component":1,"degree":1,"detached":false}],"edges":[["src/index.ts","src/utils/greeting.ts","import"],["src/index.ts","src/utils/date.ts","import"]],"components":[{"id":1,"size":3,"edges":2,"nodes":["src/index.ts","src/utils/date.ts","src/utils/greeting.ts"],"isolated_count":0,"sample":"src/index.ts","loc_sum":26,"detached":false,"tauri_frontend":0,"tauri_backend":0}],"main_component_id":1}
});</script></div><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="tree" class="tab-panel"><div class="content-container"><div data-tab-scope="_Users_maciejgad_hosted_loctree_loctree_rs_tests_fixtures_simple_ts" data-tab-name="tree" class="tree-panel"><div class="tree-header"><h3>Project tree</h3><div class="tree-controls"><button title="Expand all" class="tree-btn"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M216,48V96a8,8,0,0,1-16,0V67.31l-42.34,42.35a8,8,0,0,1-11.32-11.32L188.69,56H160a8,8,0,0,1,0-16h48A8,8,0,0,1,216,48ZM98.34,146.34,56,188.69V160a8,8,0,0,0-16,0v48a8,8,0,0,0,8,8H96a8,8,0,0,0,0-16H67.31l42.35-42.34a8,8,0,0,0-11.32-11.32ZM208,152a8,8,0,0,0-8,8v28.69l-42.34-42.35a8,8,0,0,0-11.32,11.32L188.69,200H160a8,8,0,0,0,0,16h48a8,8,0,0,0,8-8V160A8,8,0,0,0,208,152ZM67.31,56H96a8,8,0,0,0,0-16H48a8,8,0,0,0-8,8V96a8,8,0,0,0,16,0V67.31l42.34,42.35a8,8,0,0,0,11.32-11.32Z"></path></svg></button><button title="Collapse all" class="tree-btn"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M144,96V48a8,8,0,0,1,16,0V76.69l42.34-42.35a8,8,0,0,1,11.32,11.32L171.31,88H200a8,8,0,0,1,0,16H152A8,8,0,0,1,144,96ZM56,112h48a8,8,0,0,0,8-8V56a8,8,0,0,0-16,0V84.69L53.66,42.34A8,8,0,0,0,42.34,53.66L84.69,96H56a8,8,0,0,0,0,16ZM200,144H152a8,8,0,0,0-8,8v48a8,8,0,0,0,16,0V171.31l42.34,42.35a8,8,0,0,0,11.32-11.32L171.31,160H200a8,8,0,0,0,0-16Zm-96,8a8,8,0,0,0-8-8H48a8,8,0,0,0,0,16H76.69L34.34,202.34a8,8,0,0,0,11.32,11.32L88,171.31V200a8,8,0,0,0,16,0Z"></path></svg></button></div><input type="text" placeholder="Filter by path..." class="tree-filter"></div><div class="tree-container"><div class="tree-node"><div class="tree-row tree-row-dir"><div class="tree-left"><span class="tree-connector"> </span><span class="tree-chevron"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"></path></svg></span><span class="tree-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200h0a16,16,0,0,0,16,16H211.1a16,16,0,0,0,15.38-11.63l24.42-75.38A16,16,0,0,0,245,110.64ZM93.34,64l27.73,20.8a16.12,16.12,0,0,0,9.6,3.2H200v16H69.77a16,16,0,0,0-15.38,11.63L40,163.42V64Zm117.76,136H40l24.48-75.62H232Z"></path></svg></span><!><span class="tree-path"><span>src</span></span></div><div class="tree-right"><div class="tree-loc-bar"><div class="tree-loc-fill" style="width: 100%;"></div></div><span class="tree-loc">26 LOC</span></div></div><div class="tree-children"><div class="tree-node"><div class="tree-row"><div class="tree-left"><span class="tree-connector">├─ </span><!><span class="tree-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M181.66,146.34a8,8,0,0,1,0,11.32l-24,24a8,8,0,0,1-11.32-11.32L164.69,152l-18.35-18.34a8,8,0,0,1,11.32-11.32ZM104,122.34a8,8,0,0,0-11.32,0l-24,24a8,8,0,0,0,0,11.32l24,24a8,8,0,0,0,11.32-11.32L85.66,152l18.34-18.34A8,8,0,0,0,104,122.34ZM216,88V216a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-56-8h28.69L160,51.31Zm40,136V96H152a8,8,0,0,1-8-8V40H56V216H200Z"></path></svg></span><span class="tree-path"><span>index.ts</span></span></div><div class="tree-right"><div class="tree-loc-bar"><div class="tree-loc-fill" style="width: 38.46153846153847%;"></div></div><span class="tree-loc">10 LOC</span></div></div><!></div><div class="tree-node"><div class="tree-row tree-row-dir"><div class="tree-left"><span class="tree-connector">└─ </span><span class="tree-chevron"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"></path></svg></span><span class="tree-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200h0a16,16,0,0,0,16,16H211.1a16,16,0,0,0,15.38-11.63l24.42-75.38A16,16,0,0,0,245,110.64ZM93.34,64l27.73,20.8a16.12,16.12,0,0,0,9.6,3.2H200v16H69.77a16,16,0,0,0-15.38,11.63L40,163.42V64Zm117.76,136H40l24.48-75.62H232Z"></path></svg></span><!><span class="tree-path"><span>utils</span></span></div><div class="tree-right"><div class="tree-loc-bar"><div class="tree-loc-fill" style="width: 61.53846153846154%;"></div></div><span class="tree-loc">16 LOC</span></div></div><div class="tree-children"><div class="tree-node"><div class="tree-row"><div class="tree-left"><span class="tree-connector"> ├─ </span><!><span class="tree-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M181.66,146.34a8,8,0,0,1,0,11.32l-24,24a8,8,0,0,1-11.32-11.32L164.69,152l-18.35-18.34a8,8,0,0,1,11.32-11.32ZM104,122.34a8,8,0,0,0-11.32,0l-24,24a8,8,0,0,0,0,11.32l24,24a8,8,0,0,0,11.32-11.32L85.66,152l18.34-18.34A8,8,0,0,0,104,122.34ZM216,88V216a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-56-8h28.69L160,51.31Zm40,136V96H152a8,8,0,0,1-8-8V40H56V216H200Z"></path></svg></span><span class="tree-path"><span>date.ts</span></span></div><div class="tree-right"><div class="tree-loc-bar"><div class="tree-loc-fill" style="width: 30.76923076923077%;"></div></div><span class="tree-loc">8 LOC</span></div></div><!></div><div class="tree-node"><div class="tree-row"><div class="tree-left"><span class="tree-connector"> └─ </span><!><span class="tree-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256" class=""><path d="M181.66,146.34a8,8,0,0,1,0,11.32l-24,24a8,8,0,0,1-11.32-11.32L164.69,152l-18.35-18.34a8,8,0,0,1,11.32-11.32ZM104,122.34a8,8,0,0,0-11.32,0l-24,24a8,8,0,0,0,0,11.32l24,24a8,8,0,0,0,11.32-11.32L85.66,152l18.34-18.34A8,8,0,0,0,104,122.34ZM216,88V216a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-56-8h28.69L160,51.31Zm40,136V96H152a8,8,0,0,1-8-8V40H56V216H200Z"></path></svg></span><span class="tree-path"><span>greeting.ts</span></span></div><div class="tree-right"><div class="tree-loc-bar"><div class="tree-loc-fill" style="width: 30.76923076923077%;"></div></div><span class="tree-loc">8 LOC</span></div></div><!></div><!></div></div><!></div></div><!></div></div></div></div></div></div><!></main></div><script>
(() => {
// -1. Copy Button Handler
document.querySelectorAll('.copy-btn[data-copy]').forEach(btn => {
btn.addEventListener('click', () => {
const text = btn.dataset.copy;
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => btn.textContent = orig, 1500);
});
});
});
// 0. Theme Initialization & Toggle
const initTheme = () => {
const stored = localStorage.getItem('loctree-theme');
if (stored === 'dark') {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
} else if (stored === 'light') {
document.documentElement.classList.add('light');
document.documentElement.classList.remove('dark');
} else {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
};
const toggleTheme = () => {
const isDark = document.documentElement.classList.contains('dark') ||
(!document.documentElement.classList.contains('light') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
localStorage.setItem('loctree-theme', 'light');
} else {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
localStorage.setItem('loctree-theme', 'dark');
}
document.querySelectorAll('[data-role="dark"]').forEach(chk => {
chk.checked = document.documentElement.classList.contains('dark');
});
};
initTheme();
const themeToggle = document.querySelector('[data-role="theme-toggle"]');
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
// 1. Sidebar Navigation (Tab Switching)
document.querySelectorAll('.sidebar-nav .nav-item[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tabName = btn.dataset.tab;
// Update Sidebar buttons
document.querySelectorAll('.sidebar-nav .nav-item').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update all tab panels across all sections
document.querySelectorAll('.tab-panel').forEach(p => {
const isActive = p.dataset.tabName === tabName;
p.classList.toggle('active', isActive);
if (isActive && tabName === 'graph') {
window.dispatchEvent(new Event('resize'));
}
});
// Also update header tab-bar buttons if present (for visual sync)
document.querySelectorAll('.tab-bar .tab-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tabName);
});
});
});
// 2. Header Tab Switching (if still present, syncs with sidebar)
document.querySelectorAll('.tab-bar .tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tabName = btn.dataset.tab;
// Trigger sidebar button click to keep everything in sync
const sidebarBtn = document.querySelector(`.sidebar-nav .nav-item[data-tab="${tabName}"]`);
if (sidebarBtn) {
sidebarBtn.click();
}
});
});
// 3. Twins Section Toggle - handles collapsible sections in Twins tab
document.querySelectorAll('.twins-section-header[data-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.toggle;
const content = document.getElementById(targetId);
const toggle = btn.querySelector('.twins-section-toggle');
if (content) {
const isHidden = content.style.display === 'none';
content.style.display = isHidden ? 'block' : 'none';
if (toggle) {
toggle.textContent = isHidden ? '▼' : '▶';
}
// Initialize Cytoscape graph when twins-exact-content is opened
if (isHidden && targetId === 'twins-exact-content' && window.__TWINS_DATA__) {
const container = document.getElementById('twins-graph-container');
if (container && typeof buildTwinsGraph === 'function') {
buildTwinsGraph(window.__TWINS_DATA__, 'twins-graph-container');
}
}
}
});
});
// 3b. Crowds Graph Toggle - handles graph view in Crowds tab
document.querySelectorAll('.crowds-section-header[data-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.toggle;
const content = document.getElementById(targetId);
const toggle = btn.querySelector('.crowds-graph-toggle');
if (content) {
const isHidden = content.style.display === 'none';
content.style.display = isHidden ? 'block' : 'none';
if (toggle) {
toggle.textContent = isHidden ? '▼' : '▶';
}
// Initialize Cytoscape graph when crowds-graph-content is opened
if (isHidden && targetId === 'crowds-graph-content' && window.__CROWDS_DATA__) {
const container = document.getElementById('crowds-graph-container');
if (container && typeof buildCrowdsGraph === 'function') {
buildCrowdsGraph(window.__CROWDS_DATA__, 'crowds-graph-container');
}
}
}
});
});
// 4. Test Files Toggle - Hide/Show test file rows
const toggleTestsBtn = document.getElementById('toggle-tests-btn');
const toggleIcon = document.getElementById('test-toggle-icon');
const toggleText = document.getElementById('test-toggle-text');
// Initialize state from localStorage
const testsHidden = localStorage.getItem('loctree-hide-tests') === 'true';
const updateTestsVisibility = (hide) => {
const testItems = document.querySelectorAll('[data-is-test="true"]');
testItems.forEach(el => {
el.style.display = hide ? 'none' : '';
});
// Update button state
if (toggleText) {
toggleText.textContent = hide ? 'Show Tests' : 'Hide Tests';
}
if (toggleIcon) {
toggleIcon.style.opacity = hide ? '0.5' : '1';
}
// Save to localStorage
localStorage.setItem('loctree-hide-tests', hide ? 'true' : 'false');
};
// Apply initial state
updateTestsVisibility(testsHidden);
// Add click handler
if (toggleTestsBtn) {
toggleTestsBtn.addEventListener('click', () => {
const currentlyHidden = localStorage.getItem('loctree-hide-tests') === 'true';
updateTestsVisibility(!currentlyHidden);
});
}
})();
</script><script src="loctree-cytoscape.min.js"></script><script src="loctree-dagre.min.js"></script><script src="loctree-cytoscape-dagre.js"></script><script src="loctree-layout-base.js"></script><script src="loctree-cose-base.js"></script><script src="loctree-cytoscape-cose-bilkent.js"></script><script>(function () {
const graphs = window.__LOCTREE_GRAPHS || [];
const formatNum = (n) => (typeof n === "number" && n.toLocaleString ? n.toLocaleString() : n || 0);
const cyInstances = new Set();
const darkToggles = new Set();
const filterElements = (elements, opts) => {
const text = (opts.text || "").toLowerCase();
const minDeg = parseInt(opts.minDeg || "0", 10) || 0;
const allowedComponents = opts.allowedComponents || new Set();
let nodes = elements.nodes.map((n) => ({ data: { ...n.data }, position: { ...n.position } }));
if (text) nodes = nodes.filter((n) => (n.data.id || "").toLowerCase().includes(text));
if (allowedComponents.size) nodes = nodes.filter((n) => allowedComponents.has(n.data.component));
let edges = elements.edges.map((e) => ({ data: { ...e.data } }));
const nodeSet = new Set(nodes.map((n) => n.data.id));
edges = edges.filter((e) => nodeSet.has(e.data.source) && nodeSet.has(e.data.target));
if (minDeg > 0) {
const deg = {};
edges.forEach((e) => {
deg[e.data.source] = (deg[e.data.source] || 0) + 1;
deg[e.data.target] = (deg[e.data.target] || 0) + 1;
});
nodes = nodes.filter((n) => (deg[n.data.id] || 0) >= minDeg);
const filteredSet = new Set(nodes.map((n) => n.data.id));
edges = edges.filter((e) => filteredSet.has(e.data.source) && filteredSet.has(e.data.target));
}
return { nodes, edges };
};
// Graph-only dark mode (independent of page theme)
const applyGraphDarkTheme = (on, graphs) => {
graphs
.filter(Boolean)
.forEach((inst) => {
if (inst && typeof inst.style === "function") {
const style = inst.style();
// Node text color: light for dark mode, dark for light mode
style.selector("node").style("color", on ? "#eef2ff" : "#1a1a2e").update();
// Edge text background: dark for dark mode, light for light mode
style.selector("edge").style("text-background-color", on ? "#0f1115" : "#fff").update();
// Graph container background via cytoscape
inst.container().style.backgroundColor = on ? "#0f1115" : "#ffffff";
}
});
};
const setGraphDarkMode = (on) => applyGraphDarkTheme(on, Array.from(cyInstances));
const applyGraphDarkShared = (on) => {
darkToggles.forEach((chk) => {
if (chk) chk.checked = on;
});
setGraphDarkMode(on);
};
graphs.forEach((g) => {
const container = document.getElementById(g.id);
if (!container || container.dataset.enhanced === "1") return;
container.dataset.enhanced = "1";
const components = Array.isArray(g.components) ? g.components : [];
const componentMap = new Map();
components.forEach((c) => componentMap.set(c.id, c));
const detachedSet = new Set(components.filter((c) => c.detached).map((c) => c.id));
const openBase = g.openBase || null;
const originalParent = container.parentNode;
const targetParent = originalParent || container.parentNode;
// ========================================
// Side-by-side split layout
// ========================================
const splitContainer = document.createElement("div");
splitContainer.className = "graph-split-container";
// LEFT PANEL: Component list with inner scroll
const leftPanel = document.createElement("div");
leftPanel.className = "graph-left-panel";
// Component filter toolbar
const componentBar = document.createElement("div");
componentBar.className = "graph-toolbar component-toolbar";
// nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method -- SAFETY: static HTML template with no user input
componentBar.innerHTML = `
<label>Component filter:
<select data-role="component-filter">
<option value="all">All components</option>
<option value="isolates">Isolates / size≤2</option>
<option value="size">Size ≤ slider</option>
</select>
</label>
<label>threshold:
<input type="range" min="1" max="64" value="8" data-role="component-threshold" />
<span data-role="component-threshold-label">8</span>
</label>
<span class="graph-controls">
<button data-role="component-highlight">Highlight selected</button>
<button data-role="component-dim">Dim others</button>
<button data-role="component-copy">Copy file list</button>
<button data-role="component-export-json">Export JSON</button>
<button data-role="component-export-csv">Export CSV</button>
<button data-role="component-show-isolates">Show isolates</button>
</span>
`;
const componentPanel = document.createElement("div");
componentPanel.className = "component-panel";
// nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method -- SAFETY: static HTML template with no user input
componentPanel.innerHTML = `
<div class="component-panel-header">
<div><strong>Disconnected components</strong> <span class="muted" data-role="component-summary"></span></div>
<div class="panel-actions">
<label>show size ≤ <input type="number" min="1" value="8" data-role="component-size-limit" style="width:70px" /></label>
<button data-role="component-reset">Reset view</button>
</div>
</div>
<table>
<thead><tr><th>id</th><th>size</th><th>sample</th><th>isolated</th><th>edges</th><th>LOC</th><th>actions</th></tr></thead>
<tbody data-role="component-table"></tbody>
</table>
`;
leftPanel.appendChild(componentBar);
leftPanel.appendChild(componentPanel);
// RESIZE HANDLE
const resizeHandle = document.createElement("div");
resizeHandle.className = "graph-resize-handle";
// RIGHT PANEL: Graph pinned to viewport
const rightPanel = document.createElement("div");
rightPanel.className = "graph-right-panel";
// Graph controls toolbar
const toolbar = document.createElement("div");
toolbar.className = "graph-toolbar";
// nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method -- SAFETY: static HTML template with no user input
toolbar.innerHTML = `
<label>filter:
<input type="text" size="18" placeholder="path substring or /regex/" data-role="filter-text" title="Filter by path. Use plain text for substring match, or /pattern/ for regex." />
</label>
<label>min degree:
<input type="number" min="0" value="0" style="width:60px" data-role="min-degree" />
</label>
<label>layout:
<select data-role="layout-select">
<option value="cose">cose (force)</option>
<option value="dagre">dagre (hierarchy)</option>
<option value="cose-bilkent">cose-bilkent</option>
<option value="concentric" selected>concentric</option>
<option value="breadthfirst">breadthfirst</option>
<option value="preset">preset (original)</option>
</select>
</label>
<label><input type="checkbox" data-role="toggle-labels" checked /> labels</label>
<label><input type="checkbox" data-role="graph-dark" /> graph dark</label>
<span class="graph-controls">
<button data-role="fit">fit</button>
<button data-role="relayout">relayout</button>
<button data-role="reset">reset</button>
<button data-role="fullscreen">fullscreen</button>
<button data-role="png">png</button>
<button data-role="json">json</button>
</span>
<div class="graph-legend">
<span><span class="legend-dot" style="background:#4f81e1"></span> file</span>
<span><span class="legend-dot" style="background:#888"></span> import</span>
<span><span class="legend-dot" style="background:#e67e22"></span> re-export</span>
<span><span class="legend-dot" style="background:#d1830f"></span> detached</span>
</div>
`;
// Move graph container into right panel
container.style.height = ""; // Remove fixed height, let flex handle it
container.style.flex = "1";
container.style.minHeight = "0";
rightPanel.appendChild(toolbar);
rightPanel.appendChild(container);
// Assemble split layout
splitContainer.appendChild(leftPanel);
splitContainer.appendChild(resizeHandle);
splitContainer.appendChild(rightPanel);
if (targetParent) targetParent.appendChild(splitContainer);
// ========================================
// Resize handle drag functionality
// ========================================
let isResizing = false;
let startX = 0;
let startWidth = 0;
resizeHandle.addEventListener("mousedown", (e) => {
isResizing = true;
startX = e.clientX;
startWidth = leftPanel.offsetWidth;
resizeHandle.classList.add("active");
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isResizing) return;
const delta = e.clientX - startX;
const newWidth = Math.min(600, Math.max(280, startWidth + delta));
leftPanel.style.width = newWidth + "px";
});
document.addEventListener("mouseup", () => {
if (isResizing) {
isResizing = false;
resizeHandle.classList.remove("active");
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
});
const componentSelect = componentBar.querySelector('[data-role="component-filter"]');
const sizeSlider = componentBar.querySelector('[data-role="component-threshold"]');
const sizeLabel = componentBar.querySelector('[data-role="component-threshold-label"]');
const tableBody = componentPanel.querySelector('[data-role="component-table"]');
const summaryEl = componentPanel.querySelector('[data-role="component-summary"]');
const sizeLimitInput = componentPanel.querySelector('[data-role="component-size-limit"]');
const componentReset = componentPanel.querySelector('[data-role="component-reset"]');
const addComponentOptions = () => {
const sorted = [...components].sort((a, b) => a.size - b.size || a.id - b.id);
sorted.forEach((comp) => {
const opt = document.createElement("option");
opt.value = `comp-${comp.id}`;
const labelSample = comp.sample || (Array.isArray(comp.nodes) && comp.nodes[0]) || "";
opt.textContent = `C${comp.id} • ${comp.size} nodes • ${labelSample}`;
opt.dataset.size = comp.size;
componentSelect.appendChild(opt);
});
};
addComponentOptions();
const state = {
viewComponents: new Set(),
highlightComponents: new Set(),
sizeThreshold: parseInt(sizeSlider?.value || "8", 10) || 8,
dimOthers: true,
};
const syncSize = (val) => {
const safe = Math.max(1, Math.min(128, val || state.sizeThreshold));
state.sizeThreshold = safe;
if (sizeLabel) sizeLabel.textContent = safe;
if (sizeSlider && sizeSlider.value !== String(safe)) sizeSlider.value = safe;
if (sizeLimitInput && sizeLimitInput.value !== String(safe)) sizeLimitInput.value = safe;
const sizeOption = componentSelect.querySelector('option[value="size"]');
if (sizeOption) sizeOption.textContent = `Size ≤ ${safe}`;
};
syncSize(state.sizeThreshold);
// Layout configuration helper - supports multiple algorithms
const getLayoutConfig = (name, nodeCount) => {
// Animate only moderate-sized graphs (fewer than 150 nodes) to avoid performance issues
const animate = nodeCount < 150;
const configs = {
cose: {
name: "cose",
animate,
animationDuration: animate ? 500 : 0,
fit: true,
padding: 30,
nodeRepulsion: function(node) { return 8000; },
idealEdgeLength: function(edge) { return 100; },
edgeElasticity: function(edge) { return 100; },
nestingFactor: 1.2,
gravity: 1,
numIter: 1000,
initialTemp: 1000,
coolingFactor: 0.99,
minTemp: 1.0,
randomize: false,
},
"cose-bilkent": {
name: "cose-bilkent",
animate,
animationDuration: animate ? 500 : 0,
fit: true,
padding: 30,
nodeRepulsion: 4500,
idealEdgeLength: 80,
edgeElasticity: 0.45,
nestingFactor: 0.1,
gravity: 0.25,
numIter: 2500,
tile: true,
tilingPaddingVertical: 10,
tilingPaddingHorizontal: 10,
gravityRangeCompound: 1.5,
gravityCompound: 1.0,
gravityRange: 3.8,
randomize: true,
},
dagre: {
name: "dagre",
animate,
animationDuration: animate ? 500 : 0,
fit: true,
padding: 30,
rankDir: "TB", // top-to-bottom (hierarchy: caller → callee)
nodeSep: 50,
rankSep: 80,
edgeSep: 10,
ranker: "network-simplex", // tight-tree, longest-path, network-simplex
},
concentric: {
name: "concentric",
animate,
animationDuration: animate ? 500 : 0,
fit: true,
padding: 30,
minNodeSpacing: 50,
concentric: function(node) { return node.data("degree") || 0; },
levelWidth: function(nodes) { return Math.max(1, Math.ceil(nodes.length / 8)); },
clockwise: true,
startAngle: 3 / 2 * Math.PI,
},
breadthfirst: {
name: "breadthfirst",
animate,
animationDuration: animate ? 500 : 0,
fit: true,
padding: 30,
directed: true,
spacingFactor: 1.5,
circle: false,
grid: false,
avoidOverlap: true,
},
preset: {
name: "preset",
animate: false,
fit: true,
},
};
return configs[name] || configs.preset;
};
const buildElements = () => {
const rawNodes = Array.isArray(g.nodes) ? g.nodes : [];
const rawEdges = Array.isArray(g.edges) ? g.edges : [];
const nodeToComponent = new Map();
const nodes = rawNodes.map((n) => {
const size = Math.max(4, Math.min(30, Math.sqrt((n && n.loc) || 1)));
const comp = n.component || 0;
const compSize = (componentMap.get(comp) || {}).size || 0;
const detached = detachedSet.has(comp) || !!n.detached;
const isolate = (n.degree || 0) === 0 || compSize <= 2;
const id = n.id || "";
nodeToComponent.set(id, comp);
return {
data: {
id,
label: n.label || id || "",
loc: n.loc || 0,
size,
full: id || "",
component: comp,
degree: n.degree || 0,
detached,
componentSize: compSize,
isolate: isolate ? 1 : 0,
color: detached ? "#d1830f" : "#4f81e1",
},
position: { x: n.x || 0, y: n.y || 0 },
};
});
const edges = rawEdges.map((e, idx) => {
const kind = (e && e[2]) || "import";
const sourceComp = nodeToComponent.get(e[0]) || nodeToComponent.get(e[1]) || 0;
const detached = detachedSet.has(sourceComp);
const color = detached ? "#d1830f" : kind === "reexport" ? "#e67e22" : "#888";
return {
data: {
id: "e" + idx,
source: e[0],
target: e[1],
label: kind,
kind,
color,
component: sourceComp,
detached: detached ? 1 : 0,
},
};
});
return { nodes, edges };
};
const original = buildElements();
const emptyOverlay = document.createElement("div");
emptyOverlay.className = "graph-empty";
emptyOverlay.style.display = "none";
container.appendChild(emptyOverlay);
let cy = cytoscape({
container,
elements: original,
style: [
{ selector: "node", style: { label: "data(label)", "font-size": 10, "text-wrap": "wrap", "text-max-width": 120, "background-color": "data(color)", color: "#fff", width: "data(size)", height: "data(size)", "overlay-padding": 8, "overlay-opacity": 0 } },
{ selector: "node.detached", style: { "background-color": "#d1830f" } },
{ selector: "node.isolate", style: { "border-width": 2, "border-color": "#d74d26" } },
{ selector: "node.highlight", style: { "border-width": 3, "border-color": "#111", "shadow-blur": 12, "shadow-color": "#111", "shadow-opacity": 0.45, "shadow-offset-x": 0, "shadow-offset-y": 0, "z-index": 999 } },
{ selector: "node.dimmed", style: { opacity: 0.15 } },
{ selector: "edge", style: { "curve-style": "bezier", width: 1.1, "line-color": "data(color)", "target-arrow-color": "data(color)", "target-arrow-shape": "triangle", "arrow-scale": 0.7, label: "", "font-size": 9, "text-background-color": "#fff", "text-background-opacity": 0.8, "text-background-padding": 2 } },
{ selector: "edge.detached", style: { "line-color": "#d1830f", "target-arrow-color": "#d1830f" } },
{ selector: "edge.highlight", style: { width: 2, opacity: 0.9 } },
{ selector: "edge.dimmed", style: { opacity: 0.08 } },
],
layout: { name: "preset", animate: false, fit: true },
});
cyInstances.add(cy);
const download = (filename, content, type) => {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 500);
};
const gatherSelectedComponents = () => {
if (state.highlightComponents.size) return new Set(state.highlightComponents);
if (state.viewComponents.size) return new Set(state.viewComponents);
return new Set();
};
const applyHighlight = (forceDim) => {
const highlightSet = gatherSelectedComponents();
const dim = forceDim === undefined ? state.dimOthers : forceDim;
cy.nodes().removeClass("dimmed highlight isolate detached");
cy.edges().removeClass("dimmed highlight detached");
cy.nodes().filter((n) => n.data("detached")).addClass("detached");
cy.edges().filter((e) => e.data("detached")).addClass("detached");
cy.nodes()
.filter((n) => (n.data("isolate") || 0) === 1 || (n.data("componentSize") || 0) <= 2)
.addClass("isolate");
if (!highlightSet.size) return;
const nodes = cy.nodes().filter((n) => highlightSet.has(n.data("component")));
const edges = cy.edges().filter((e) => highlightSet.has(e.data("component")));
nodes.addClass("highlight");
edges.addClass("highlight");
if (dim) {
cy.nodes().not(nodes).addClass("dimmed");
cy.edges().not(edges).addClass("dimmed");
}
};
const layoutSelect = toolbar.querySelector('[data-role="layout-select"]');
const getSelectedLayout = () => layoutSelect?.value || "concentric";
const applyFilters = (runLayout = true) => {
const text = (toolbar.querySelector('[data-role="filter-text"]')?.value || "").toLowerCase();
const minDeg = parseInt(toolbar.querySelector('[data-role="min-degree"]')?.value || "0", 10) || 0;
const allowedComponents = state.viewComponents;
const filtered = filterElements(original, { text, minDeg, allowedComponents });
let nodes = filtered.nodes;
let edges = filtered.edges;
if (nodes.length === 0) {
emptyOverlay.style.display = "block";
cy.elements().remove();
return;
}
emptyOverlay.style.display = "none";
cy.elements().remove();
cy.add({ nodes, edges });
const showLabels = toolbar.querySelector('[data-role="toggle-labels"]').checked;
const autoHide = nodes.length > 800;
const labelsOn = showLabels && !autoHide;
cy.style().selector("node").style("label", labelsOn ? "data(label)" : "").update();
if (runLayout) {
const layoutName = getSelectedLayout();
const layoutConfig = getLayoutConfig(layoutName, nodes.length);
cy.layout(layoutConfig).run();
}
applyHighlight();
};
const runRelayout = () => {
const layoutName = getSelectedLayout();
const nodeCount = cy.nodes().length;
const layoutConfig = getLayoutConfig(layoutName, nodeCount);
cy.layout(layoutConfig).run();
};
// Fit / reset / relayout / dark / fullscreen
const fitBtn = toolbar.querySelector('[data-role="fit"]');
const relayoutBtn = toolbar.querySelector('[data-role="relayout"]');
const resetBtn = toolbar.querySelector('[data-role="reset"]');
const darkChk = toolbar.querySelector('[data-role="graph-dark"]');
const fsBtn = toolbar.querySelector('[data-role="fullscreen"]');
const pngBtn = toolbar.querySelector('[data-role="png"]');
const jsonBtn = toolbar.querySelector('[data-role="json"]');
if (fitBtn) fitBtn.addEventListener("click", () => cy.fit());
if (relayoutBtn) relayoutBtn.addEventListener("click", runRelayout);
if (layoutSelect) layoutSelect.addEventListener("change", runRelayout);
if (resetBtn)
resetBtn.addEventListener("click", () => {
cy.elements().remove();
cy.add(original);
state.viewComponents = new Set();
state.highlightComponents = new Set();
layoutSelect.value = "preset";
applyFilters(false);
cy.layout({ name: "preset", animate: false, fit: true }).run();
});
if (pngBtn)
pngBtn.addEventListener("click", () => {
const dark = darkChk && darkChk.checked;
const dataUrl = cy.png({ bg: dark ? "#0f1115" : "#ffffff", full: true, scale: 2 });
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${g.id}-graph.png`;
document.body.appendChild(a);
a.click();
a.remove();
});
if (jsonBtn)
jsonBtn.addEventListener("click", () => {
const payload = {
nodes: cy.nodes().map((n) => n.data()),
edges: cy.edges().map((e) => ({ source: e.data("source"), target: e.data("target"), kind: e.data("kind") })),
filter: toolbar.querySelector('[data-role="filter-text"]')?.value || "",
minDegree: parseInt(toolbar.querySelector('[data-role="min-degree"]')?.value || "0", 10) || 0,
components,
highlightedComponents: Array.from(state.highlightComponents),
viewedComponents: Array.from(state.viewComponents),
};
download(`${g.id}-graph.json`, JSON.stringify(payload, null, 2), "application/json");
});
if (darkChk) {
darkToggles.add(darkChk);
darkChk.addEventListener("change", () => applyGraphDarkShared(darkChk.checked));
}
const fsTarget = container;
if (fsBtn && fsTarget && fsTarget.requestFullscreen) {
fsBtn.addEventListener("click", () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
fsTarget.requestFullscreen().catch(() => {});
}
});
document.addEventListener("fullscreenchange", () => {
fsBtn.textContent = document.fullscreenElement ? "exit fullscreen" : "fullscreen";
if (!document.fullscreenElement) cy.fit();
});
}
// Tooltip on hover/click (sticky behavior)
const tooltip = document.createElement("div");
tooltip.style.position = "fixed";
tooltip.style.pointerEvents = "auto";
tooltip.style.background = "#111";
tooltip.style.color = "#fff";
tooltip.style.padding = "6px 8px";
tooltip.style.borderRadius = "6px";
tooltip.style.fontSize = "12px";
tooltip.style.display = "none";
tooltip.style.zIndex = 9999;
document.body.appendChild(tooltip);
let nodeHover = false;
let tooltipHover = false;
let hideTimeout = null;
const hideTip = () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
nodeHover = false;
tooltipHover = false;
tooltip.style.display = "none";
};
const scheduleHide = () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
}
hideTimeout = setTimeout(() => {
if (!nodeHover && !tooltipHover) {
hideTip();
}
}, 350);
};
const showTip = (evt, node) => {
// Cancel any pending hide
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const data = node.data();
const path = data.full || data.id;
const comp = componentMap.get(data.component);
const compLabel = comp ? `C${comp.id} (${comp.size} nodes${comp.detached ? ", detached" : ""})` : "—";
// Get current filter text for highlighting
const filterInput = toolbar.querySelector('[data-role="filter-text"]');
const filterText = (filterInput?.value || "").toLowerCase().trim();
// Get incoming/outgoing edges for context
const incomingEdges = cy.edges().filter(e => e.data("target") === data.id);
const outgoingEdges = cy.edges().filter(e => e.data("source") === data.id);
// Build tooltip using safe DOM APIs (no innerHTML with user data)
tooltip.textContent = ""; // Clear previous content
// Row 1: Path with optional highlight
const pathRow = document.createElement("div");
pathRow.style.marginBottom = "4px";
const pathStrong = document.createElement("strong");
if (filterText && path.toLowerCase().includes(filterText)) {
// Case-insensitive highlight using DOM nodes
const idx = path.toLowerCase().indexOf(filterText);
pathStrong.appendChild(document.createTextNode(path.slice(0, idx)));
const mark = document.createElement("mark");
mark.style.cssText = "background:#ffd700;color:#000;padding:0 2px;border-radius:2px";
mark.textContent = path.slice(idx, idx + filterText.length);
pathStrong.appendChild(mark);
pathStrong.appendChild(document.createTextNode(path.slice(idx + filterText.length)));
} else {
pathStrong.textContent = path;
}
pathRow.appendChild(pathStrong);
tooltip.appendChild(pathRow);
// Row 2: LOC and degree
const statsRow = document.createElement("div");
statsRow.textContent = `LOC: ${data.loc || 0} | degree: ${data.degree || 0}`;
tooltip.appendChild(statsRow);
// Row 3: Edge info
const edgeRow = document.createElement("div");
edgeRow.textContent = `imports: ${outgoingEdges.length} | imported by: ${incomingEdges.length}`;
tooltip.appendChild(edgeRow);
// Row 4: Component info
const compRow = document.createElement("div");
compRow.textContent = `component: ${compLabel}`;
tooltip.appendChild(compRow);
// Row 5: Actions (copy button + open link)
const actionsRow = document.createElement("div");
actionsRow.style.cssText = "margin-top:4px;display:flex;gap:8px;align-items:center";
const copyBtn = document.createElement("button");
copyBtn.textContent = "copy path";
copyBtn.style.cssText = "font-size:10px;cursor:pointer";
copyBtn.addEventListener("click", () => navigator.clipboard.writeText(path));
actionsRow.appendChild(copyBtn);
if (openBase) {
const openLink = document.createElement("a");
openLink.href = `${openBase}/open?f=${encodeURIComponent(path)}&l=1`;
openLink.textContent = "open in editor";
openLink.style.cssText = "color:#6af;text-decoration:underline;font-size:10px";
actionsRow.appendChild(openLink);
}
tooltip.appendChild(actionsRow);
const rect = container.getBoundingClientRect();
// Fixed positioning is relative to viewport, no scroll offset needed
let left = rect.left + evt.renderedPosition.x + 12;
let top = rect.top + evt.renderedPosition.y + 12;
// Ensure tooltip stays within viewport bounds (measure after content)
tooltip.style.visibility = "hidden";
tooltip.style.display = "block";
const bounds = tooltip.getBoundingClientRect();
const tooltipWidth = bounds.width || 220;
const tooltipHeight = bounds.height || 120;
const maxLeft = window.innerWidth - tooltipWidth - 10;
const maxTop = window.innerHeight - tooltipHeight - 10;
if (left > maxLeft) left = maxLeft;
if (top > maxTop) top = Math.max(10, top - tooltipHeight - 24);
tooltip.style.left = left + "px";
tooltip.style.top = top + "px";
tooltip.style.visibility = "visible";
nodeHover = true;
};
tooltip.addEventListener("mouseenter", () => {
tooltipHover = true;
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
});
tooltip.addEventListener("mouseleave", () => {
tooltipHover = false;
scheduleHide();
});
cy.off("mouseover");
cy.off("mouseout");
cy.off("tap");
cy.off("tapdrag");
cy.off("pan");
cy.off("zoom");
cy.on("mouseover", "node", (evt) => {
nodeHover = true;
if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
showTip(evt, evt.target);
});
cy.on("mouseout", "node", () => {
nodeHover = false;
scheduleHide();
});
cy.on("tap", "node", (evt) => {
nodeHover = true;
showTip(evt, evt.target);
});
cy.on("tapdrag", "node", () => {
nodeHover = false;
hideTip();
});
// Hide tooltip on pan/zoom to avoid stale position
cy.on("pan zoom", () => {
if (tooltip.style.display !== "none") {
hideTip();
}
});
const updateComponentFilter = () => {
const val = componentSelect.value;
const set = new Set();
if (val === "isolates") {
components.filter((c) => c.size <= 2 || c.isolated_count > 0).forEach((c) => set.add(c.id));
} else if (val === "size") {
components.filter((c) => c.size <= state.sizeThreshold).forEach((c) => set.add(c.id));
} else if (val.startsWith("comp-")) {
const id = parseInt(val.replace("comp-", ""), 10);
if (Number.isFinite(id)) set.add(id);
}
state.viewComponents = set;
state.highlightComponents = new Set(set);
applyFilters();
};
const renderComponentTable = () => {
const limit = parseInt(sizeLimitInput.value || state.sizeThreshold, 10) || state.sizeThreshold;
syncSize(limit);
const rows = [...components].sort((a, b) => a.size - b.size || a.id - b.id);
const filtered = rows.filter((c) => c.size <= limit);
tableBody.textContent = ""; // Clear using safe DOM API
filtered.forEach((comp) => {
const sample = comp.sample || (comp.nodes && comp.nodes[0]) || "";
const sampleHref = openBase ? `${openBase}/open?f=${encodeURIComponent(sample)}&l=1` : null;
const warn = comp.detached ? " (detached)" : "";
const edgeCount = comp.edges !== undefined ? comp.edges : comp.edge_count;
// Build table row using safe DOM APIs (no innerHTML with user data)
const tr = document.createElement("tr");
// Cell 1: Component ID with warning
const td1 = document.createElement("td");
td1.textContent = `C${comp.id}${warn}`;
tr.appendChild(td1);
// Cell 2: Size
const td2 = document.createElement("td");
td2.textContent = comp.size;
tr.appendChild(td2);
// Cell 3: Sample file (link or code)
const td3 = document.createElement("td");
if (sampleHref) {
const link = document.createElement("a");
link.href = sampleHref;
link.textContent = sample;
td3.appendChild(link);
} else {
const code = document.createElement("code");
code.textContent = sample;
td3.appendChild(code);
}
tr.appendChild(td3);
// Cell 4: Isolated count
const td4 = document.createElement("td");
td4.textContent = comp.isolated_count;
tr.appendChild(td4);
// Cell 5: Edge count
const td5 = document.createElement("td");
td5.textContent = edgeCount || 0;
tr.appendChild(td5);
// Cell 6: LOC sum
const td6 = document.createElement("td");
td6.textContent = formatNum(comp.loc_sum);
tr.appendChild(td6);
// Cell 7: Highlight button
const td7 = document.createElement("td");
const btn = document.createElement("button");
btn.setAttribute("data-role", "component-focus");
btn.setAttribute("data-comp", comp.id);
btn.textContent = "Highlight";
td7.appendChild(btn);
tr.appendChild(td7);
tableBody.appendChild(tr);
});
summaryEl.textContent = `${filtered.length} / ${components.length} components ≤ ${limit} nodes • detached: ${detachedSet.size} • isolates: ${
components.filter((c) => c.size <= 2 || c.isolated_count > 0).length
}`;
tableBody.querySelectorAll('[data-role="component-focus"]').forEach((btn) => {
btn.addEventListener("click", (evt) => {
const compId = parseInt(evt.currentTarget.getAttribute("data-comp"), 10);
if (!Number.isFinite(compId)) return;
componentSelect.value = `comp-${compId}`;
state.viewComponents = new Set([compId]);
state.highlightComponents = new Set([compId]);
applyFilters();
const nodes = cy.nodes().filter((n) => n.data("component") === compId);
if (nodes.length) cy.fit(nodes, 30);
});
});
};
const showIsolatesBtn = componentBar.querySelector('[data-role="component-show-isolates"]');
const highlightBtn = componentBar.querySelector('[data-role="component-highlight"]');
const dimBtn = componentBar.querySelector('[data-role="component-dim"]');
const copyBtn = componentBar.querySelector('[data-role="component-copy"]');
const exportJsonBtn = componentBar.querySelector('[data-role="component-export-json"]');
const exportCsvBtn = componentBar.querySelector('[data-role="component-export-csv"]');
const gatherNodesForExport = () => {
const target = gatherSelectedComponents();
const nodes = target.size ? cy.nodes().filter((n) => target.has(n.data("component"))) : cy.nodes();
return nodes.map((n) => n.data());
};
if (showIsolatesBtn) showIsolatesBtn.addEventListener("click", () => {
componentSelect.value = "isolates";
updateComponentFilter();
});
if (componentSelect) componentSelect.addEventListener("change", updateComponentFilter);
if (sizeSlider)
sizeSlider.addEventListener("input", (e) => {
syncSize(parseInt(e.target.value, 10));
if (componentSelect.value === "size") updateComponentFilter();
renderComponentTable();
});
if (sizeLimitInput)
sizeLimitInput.addEventListener("input", (e) => {
syncSize(parseInt(e.target.value, 10));
if (componentSelect.value === "size") updateComponentFilter();
renderComponentTable();
});
if (componentReset)
componentReset.addEventListener("click", () => {
componentSelect.value = "all";
state.viewComponents = new Set();
state.highlightComponents = new Set();
applyFilters();
});
if (highlightBtn)
highlightBtn.addEventListener("click", () => {
state.dimOthers = false;
applyHighlight(false);
const comps = gatherSelectedComponents();
if (comps.size) {
const nodes = cy.nodes().filter((n) => comps.has(n.data("component")));
if (nodes.length) cy.fit(nodes, 30);
}
});
if (dimBtn) dimBtn.addEventListener("click", () => {
state.dimOthers = true;
applyHighlight(true);
});
if (copyBtn)
copyBtn.addEventListener("click", () => {
const nodes = gatherNodesForExport();
const lines = nodes.map((n) => `${n.id || ""}, loc=${n.loc || 0}, degree=${n.degree || 0}, comp=C${n.component || "?"}`);
navigator.clipboard.writeText(lines.join("\n"));
});
if (exportJsonBtn)
exportJsonBtn.addEventListener("click", () => {
const nodes = gatherNodesForExport();
download(`${g.id}-component.json`, JSON.stringify(nodes, null, 2), "application/json");
});
if (exportCsvBtn)
exportCsvBtn.addEventListener("click", () => {
const nodes = gatherNodesForExport();
const header = "path,loc,degree,component";
const rows = nodes.map((n) => `${n.id || ""},${n.loc || 0},${n.degree || 0},C${n.component || ""}`);
download(`${g.id}-component.csv`, [header, ...rows].join("\n"), "text/csv");
});
toolbar.querySelectorAll("input").forEach((inp) => {
inp.addEventListener("input", () => applyFilters());
inp.addEventListener("change", () => applyFilters());
});
renderComponentTable();
applyFilters();
// Initial fit after layout completes (layout is async)
cy.one("layoutstop", () => {
cy.fit();
});
});
})();
</script><script>/**
* Twin DNA Graph Visualization
*
* Visualizes files that export symbols with the same name (twins/dead parrots).
* - Nodes = files that export symbols
* - Edges = shared symbol names (twins) between files
* - Edge thickness = number of shared symbols
* - Node color = gradient from green (0 twins) to red (many twins)
* - Node size = total exports from that file
*
* Interactive features:
* - Hover on node → tooltip with file path, dead parrots list
* - Hover on edge → tooltip with shared symbols
* - Click on node → highlight all connections
* - Double-click → open in editor (loctree:// URL)
*/
(function() {
/**
* Builds and renders the Twins Graph using Cytoscape.js
*
* @param {Object} twinsData - The twins analysis data
* @param {Array} twinsData.exactTwins - Array of {symbol: string, files: string[]}
* @param {Array} twinsData.deadParrots - Array of {name: string, file: string, line: number}
* @param {string} containerId - ID of the container element
* @param {string} [openBase] - Base URL for opening files in editor (optional)
*/
window.buildTwinsGraph = function(twinsData, containerId, openBase) {
const container = document.getElementById(containerId);
if (!container) {
console.error(`Container ${containerId} not found`);
return null;
}
// Process twins data to build graph structure
const { nodes, edges, stats } = processTwinsData(twinsData);
// Create Cytoscape instance with stunning visuals
const cy = cytoscape({
container: container,
elements: { nodes, edges },
style: getTwinsGraphStyle(stats.maxDeadParrots, stats.maxSharedSymbols),
layout: {
name: 'cose',
animate: true,
animationDuration: 800,
animationEasing: 'ease-out-cubic',
fit: true,
padding: 50,
nodeRepulsion: function(node) {
// More repulsion for nodes with many dead parrots (spread them out)
const deadParrots = node.data('deadParrots').length;
return 4000 + (deadParrots * 500);
},
idealEdgeLength: function(edge) {
// Shorter edges for more shared symbols (pull them together)
const sharedCount = edge.data('sharedSymbols').length;
return Math.max(80, 200 - (sharedCount * 10));
},
edgeElasticity: function(edge) {
// More elastic edges for stronger connections
const sharedCount = edge.data('sharedSymbols').length;
return 100 + (sharedCount * 20);
},
gravity: 0.5,
numIter: 1500,
initialTemp: 1000,
coolingFactor: 0.95,
minTemp: 1.0,
},
minZoom: 0.3,
maxZoom: 3,
wheelSensitivity: 0.2,
});
// Add interactive features
setupInteractivity(cy, openBase, stats);
// Add toolbar controls
setupToolbar(cy, container, containerId, stats);
return cy;
};
/**
* Process twins data into Cytoscape-compatible nodes and edges
*/
function processTwinsData(twinsData) {
const { exactTwins, deadParrots } = twinsData;
// Build file->exports map and file->deadParrots map
const fileExports = new Map(); // file -> Set of symbol names
const fileDeadParrots = new Map(); // file -> Array of dead parrot objects
const fileConnections = new Map(); // file -> Set of connected files
// Process dead parrots
deadParrots.forEach(dp => {
if (!fileDeadParrots.has(dp.file)) {
fileDeadParrots.set(dp.file, []);
}
fileDeadParrots.get(dp.file).push(dp);
});
// Process exact twins to build connections
exactTwins.forEach(twin => {
const { symbol, files } = twin;
// Add symbol to each file's exports
files.forEach(file => {
if (!fileExports.has(file)) {
fileExports.set(file, new Set());
}
fileExports.get(file).add(symbol);
});
// Create connections between files that share this symbol
for (let i = 0; i < files.length; i++) {
for (let j = i + 1; j < files.length; j++) {
const file1 = files[i];
const file2 = files[j];
if (!fileConnections.has(file1)) {
fileConnections.set(file1, new Map());
}
if (!fileConnections.has(file2)) {
fileConnections.set(file2, new Map());
}
// Track shared symbols between these two files
if (!fileConnections.get(file1).has(file2)) {
fileConnections.get(file1).set(file2, []);
}
if (!fileConnections.get(file2).has(file1)) {
fileConnections.get(file2).set(file1, []);
}
fileConnections.get(file1).get(file2).push(symbol);
fileConnections.get(file2).get(file1).push(symbol);
}
}
});
// Build nodes
const nodes = [];
const allFiles = new Set([...fileExports.keys(), ...fileDeadParrots.keys()]);
let maxDeadParrots = 0;
let maxExports = 0;
allFiles.forEach(file => {
const exports = fileExports.get(file) || new Set();
const deadParrotsForFile = fileDeadParrots.get(file) || [];
maxDeadParrots = Math.max(maxDeadParrots, deadParrotsForFile.length);
maxExports = Math.max(maxExports, exports.size);
nodes.push({
data: {
id: file,
label: getFileLabel(file),
fullPath: file,
exportCount: exports.size,
deadParrots: deadParrotsForFile,
deadParrotCount: deadParrotsForFile.length,
}
});
});
// Build edges
const edges = [];
const processedPairs = new Set();
fileConnections.forEach((connections, sourceFile) => {
connections.forEach((sharedSymbols, targetFile) => {
// Avoid duplicate edges
const pairKey = [sourceFile, targetFile].sort().join('|||');
if (processedPairs.has(pairKey)) return;
processedPairs.add(pairKey);
edges.push({
data: {
id: `${sourceFile}--${targetFile}`,
source: sourceFile,
target: targetFile,
sharedSymbols: sharedSymbols,
sharedCount: sharedSymbols.length,
}
});
});
});
const stats = {
maxDeadParrots,
maxExports,
maxSharedSymbols: Math.max(...edges.map(e => e.data.sharedCount), 1),
totalFiles: allFiles.size,
totalTwins: exactTwins.length,
totalDeadParrots: deadParrots.length,
};
return { nodes, edges, stats };
}
/**
* Get a shortened label for a file path
*/
function getFileLabel(filePath) {
const parts = filePath.split('/');
if (parts.length <= 2) return filePath;
// Show last 2 parts: "dir/file.rs"
return parts.slice(-2).join('/');
}
/**
* Generate Cytoscape style with dynamic gradients
*/
function getTwinsGraphStyle(maxDeadParrots, maxSharedSymbols) {
return [
// Base node style
{
selector: 'node',
style: {
'label': 'data(label)',
'font-size': 11,
'font-weight': 'bold',
'text-wrap': 'wrap',
'text-max-width': 140,
'text-valign': 'center',
'text-halign': 'center',
'color': '#fff',
'text-outline-color': '#000',
'text-outline-width': 2,
'background-color': function(ele) {
return getNodeColor(ele.data('deadParrotCount'), maxDeadParrots);
},
'width': function(ele) {
// Size based on export count
const exportCount = ele.data('exportCount') || 0;
return Math.max(30, Math.min(80, 30 + exportCount * 3));
},
'height': function(ele) {
const exportCount = ele.data('exportCount') || 0;
return Math.max(30, Math.min(80, 30 + exportCount * 3));
},
'border-width': 3,
'border-color': function(ele) {
const deadParrots = ele.data('deadParrotCount') || 0;
return deadParrots > 0 ? '#ff0000' : '#4a90e2';
},
'border-opacity': function(ele) {
const deadParrots = ele.data('deadParrotCount') || 0;
return deadParrots > 0 ? 0.8 : 0.4;
},
'transition-property': 'background-color, border-color, border-width',
'transition-duration': '0.3s',
'overlay-padding': 10,
'overlay-opacity': 0,
}
},
// Highlighted node
{
selector: 'node.highlight',
style: {
'border-width': 5,
'border-color': '#ffd700',
'border-opacity': 1,
'shadow-blur': 20,
'shadow-color': '#ffd700',
'shadow-opacity': 0.8,
'shadow-offset-x': 0,
'shadow-offset-y': 0,
'z-index': 999,
}
},
// Dimmed node
{
selector: 'node.dimmed',
style: {
'opacity': 0.2,
}
},
// Base edge style
{
selector: 'edge',
style: {
'curve-style': 'bezier',
'width': function(ele) {
// Thickness based on shared symbols count
const count = ele.data('sharedCount') || 1;
return Math.max(1, Math.min(12, count * 1.5));
},
'line-color': function(ele) {
return getEdgeColor(ele.data('sharedCount'), maxSharedSymbols);
},
'target-arrow-color': function(ele) {
return getEdgeColor(ele.data('sharedCount'), maxSharedSymbols);
},
'target-arrow-shape': 'none',
'opacity': 0.6,
'label': '',
'font-size': 9,
'text-background-color': '#000',
'text-background-opacity': 0.7,
'text-background-padding': 3,
'color': '#fff',
'transition-property': 'width, line-color, opacity',
'transition-duration': '0.3s',
}
},
// Highlighted edge
{
selector: 'edge.highlight',
style: {
'width': function(ele) {
const count = ele.data('sharedCount') || 1;
return Math.max(3, Math.min(16, count * 2));
},
'opacity': 1,
'z-index': 998,
'label': function(ele) {
const symbols = ele.data('sharedSymbols') || [];
return symbols.slice(0, 3).join(', ') + (symbols.length > 3 ? '...' : '');
},
}
},
// Dimmed edge
{
selector: 'edge.dimmed',
style: {
'opacity': 0.1,
}
},
];
}
/**
* Get node color based on dead parrot count (green -> yellow -> orange -> red)
*/
function getNodeColor(deadParrotCount, maxDeadParrots) {
if (deadParrotCount === 0) {
return '#22c55e'; // green - no dead parrots
}
// Normalize to 0-1 range
const ratio = Math.min(deadParrotCount / Math.max(maxDeadParrots, 1), 1);
// Color gradient: green -> yellow -> orange -> red
if (ratio < 0.25) {
// Green to yellow
const t = ratio / 0.25;
return interpolateColor('#22c55e', '#eab308', t);
} else if (ratio < 0.5) {
// Yellow to orange
const t = (ratio - 0.25) / 0.25;
return interpolateColor('#eab308', '#f97316', t);
} else if (ratio < 0.75) {
// Orange to deep orange
const t = (ratio - 0.5) / 0.25;
return interpolateColor('#f97316', '#ea580c', t);
} else {
// Deep orange to red
const t = (ratio - 0.75) / 0.25;
return interpolateColor('#ea580c', '#dc2626', t);
}
}
/**
* Get edge color based on shared symbols count
*/
function getEdgeColor(sharedCount, maxSharedSymbols) {
// Normalize to 0-1 range
const ratio = Math.min(sharedCount / Math.max(maxSharedSymbols, 1), 1);
// Color gradient: light blue -> purple -> magenta
if (ratio < 0.5) {
const t = ratio / 0.5;
return interpolateColor('#60a5fa', '#a855f7', t); // blue to purple
} else {
const t = (ratio - 0.5) / 0.5;
return interpolateColor('#a855f7', '#ec4899', t); // purple to magenta
}
}
/**
* Interpolate between two hex colors
*/
function interpolateColor(color1, color2, t) {
const c1 = hexToRgb(color1);
const c2 = hexToRgb(color2);
const r = Math.round(c1.r + (c2.r - c1.r) * t);
const g = Math.round(c1.g + (c2.g - c1.g) * t);
const b = Math.round(c1.b + (c2.b - c1.b) * t);
return rgbToHex(r, g, b);
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
/**
* Setup interactive features (hover, click, double-click)
*/
function setupInteractivity(cy, openBase, stats) {
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'twins-graph-tooltip';
tooltip.style.cssText = `
position: fixed;
pointer-events: none;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
display: none;
z-index: 10000;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 400px;
backdrop-filter: blur(10px);
`;
document.body.appendChild(tooltip);
let nodeHoverTimeout = null;
let edgeHoverTimeout = null;
// Node hover - show file info and dead parrots
cy.on('mouseover', 'node', function(evt) {
const node = evt.target;
const data = node.data();
clearTimeout(nodeHoverTimeout);
nodeHoverTimeout = setTimeout(() => {
tooltip.innerHTML = ''; // Clear previous
// File path
const pathDiv = document.createElement('div');
pathDiv.style.cssText = 'font-weight: bold; margin-bottom: 8px; color: #60a5fa;';
pathDiv.textContent = data.fullPath;
tooltip.appendChild(pathDiv);
// Stats
const statsDiv = document.createElement('div');
statsDiv.style.cssText = 'margin-bottom: 8px; font-size: 11px; opacity: 0.9;';
statsDiv.innerHTML = `
<div>Exports: ${data.exportCount}</div>
<div>Dead Parrots: ${data.deadParrotCount}</div>
<div>Connections: ${node.degree()}</div>
`;
tooltip.appendChild(statsDiv);
// Dead parrots list
if (data.deadParrots.length > 0) {
const dpTitle = document.createElement('div');
dpTitle.style.cssText = 'font-weight: bold; margin-top: 8px; color: #f87171;';
dpTitle.textContent = 'Dead Parrots:';
tooltip.appendChild(dpTitle);
const dpList = document.createElement('ul');
dpList.style.cssText = 'margin: 4px 0 0 0; padding-left: 20px; font-size: 10px;';
data.deadParrots.slice(0, 10).forEach(dp => {
const li = document.createElement('li');
li.style.cssText = 'margin: 2px 0;';
li.textContent = `${dp.name} (line ${dp.line})`;
dpList.appendChild(li);
});
if (data.deadParrots.length > 10) {
const more = document.createElement('li');
more.style.cssText = 'margin: 2px 0; font-style: italic;';
more.textContent = `... and ${data.deadParrots.length - 10} more`;
dpList.appendChild(more);
}
tooltip.appendChild(dpList);
}
// Open in editor hint
if (openBase) {
const hint = document.createElement('div');
hint.style.cssText = 'margin-top: 8px; font-size: 10px; opacity: 0.7; font-style: italic;';
hint.textContent = 'Double-click to open in editor';
tooltip.appendChild(hint);
}
tooltip.style.display = 'block';
positionTooltip(evt.renderedPosition);
}, 100);
});
cy.on('mouseout', 'node', function() {
clearTimeout(nodeHoverTimeout);
tooltip.style.display = 'none';
});
// Edge hover - show shared symbols
cy.on('mouseover', 'edge', function(evt) {
const edge = evt.target;
const data = edge.data();
clearTimeout(edgeHoverTimeout);
edgeHoverTimeout = setTimeout(() => {
tooltip.innerHTML = '';
const titleDiv = document.createElement('div');
titleDiv.style.cssText = 'font-weight: bold; margin-bottom: 8px; color: #a855f7;';
titleDiv.textContent = `${data.sharedCount} Shared Symbol${data.sharedCount > 1 ? 's' : ''}`;
tooltip.appendChild(titleDiv);
const symbolList = document.createElement('ul');
symbolList.style.cssText = 'margin: 0; padding-left: 20px; font-size: 11px;';
data.sharedSymbols.forEach(symbol => {
const li = document.createElement('li');
li.style.cssText = 'margin: 2px 0;';
li.textContent = symbol;
symbolList.appendChild(li);
});
tooltip.appendChild(symbolList);
tooltip.style.display = 'block';
positionTooltip(evt.renderedPosition);
}, 100);
});
cy.on('mouseout', 'edge', function() {
clearTimeout(edgeHoverTimeout);
tooltip.style.display = 'none';
});
// Click on node - highlight connections
cy.on('tap', 'node', function(evt) {
const node = evt.target;
// Clear previous highlights
cy.elements().removeClass('highlight dimmed');
// Highlight this node and its neighborhood
node.addClass('highlight');
node.neighborhood().addClass('highlight');
// Dim everything else
cy.elements().not(node.neighborhood().union(node)).addClass('dimmed');
});
// Click on background - clear highlights
cy.on('tap', function(evt) {
if (evt.target === cy) {
cy.elements().removeClass('highlight dimmed');
}
});
// Double-click on node - open in editor
if (openBase) {
cy.on('dbltap', 'node', function(evt) {
const node = evt.target;
const filePath = node.data('fullPath');
const url = `${openBase}/open?f=${encodeURIComponent(filePath)}&l=1`;
window.open(url, '_blank');
});
}
function positionTooltip(renderedPos) {
const containerRect = cy.container().getBoundingClientRect();
let left = containerRect.left + renderedPos.x + 15;
let top = containerRect.top + renderedPos.y + 15;
// Keep tooltip within viewport
const tooltipRect = tooltip.getBoundingClientRect();
const maxLeft = window.innerWidth - tooltipRect.width - 10;
const maxTop = window.innerHeight - tooltipRect.height - 10;
if (left > maxLeft) left = Math.max(10, renderedPos.x - tooltipRect.width - 15);
if (top > maxTop) top = Math.max(10, renderedPos.y - tooltipRect.height - 15);
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
}
/**
* Setup toolbar with controls
*/
function setupToolbar(cy, container, containerId, stats) {
// Create toolbar
const toolbar = document.createElement('div');
toolbar.className = 'twins-graph-toolbar';
toolbar.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
padding: 12px 16px;
border-radius: 8px;
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
font-size: 12px;
color: #fff;
`;
// Stats display
const statsDiv = document.createElement('div');
statsDiv.style.cssText = 'display: flex; gap: 16px; margin-right: auto;';
statsDiv.innerHTML = `
<span><strong>Files:</strong> ${stats.totalFiles}</span>
<span><strong>Twins:</strong> ${stats.totalTwins}</span>
<span><strong>Dead Parrots:</strong> ${stats.totalDeadParrots}</span>
`;
toolbar.appendChild(statsDiv);
// Layout selector
const layoutLabel = document.createElement('label');
layoutLabel.style.cssText = 'display: flex; gap: 6px; align-items: center;';
layoutLabel.innerHTML = '<span>Layout:</span>';
const layoutSelect = document.createElement('select');
layoutSelect.style.cssText = 'background: #1a1a2e; color: #fff; border: 1px solid #444; padding: 4px 8px; border-radius: 4px;';
layoutSelect.innerHTML = `
<option value="cose">Force (COSE)</option>
<option value="cose-bilkent">Force (Bilkent)</option>
<option value="concentric">Concentric</option>
<option value="circle">Circle</option>
<option value="grid">Grid</option>
`;
layoutLabel.appendChild(layoutSelect);
toolbar.appendChild(layoutLabel);
layoutSelect.addEventListener('change', () => {
const layoutName = layoutSelect.value;
cy.layout({
name: layoutName,
animate: true,
animationDuration: 600,
fit: true,
padding: 50,
}).run();
});
// Fit button
const fitBtn = document.createElement('button');
fitBtn.textContent = 'Fit';
fitBtn.style.cssText = 'background: #4a90e2; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;';
fitBtn.addEventListener('click', () => cy.fit(null, 30));
toolbar.appendChild(fitBtn);
// Reset button
const resetBtn = document.createElement('button');
resetBtn.textContent = 'Reset';
resetBtn.style.cssText = 'background: #666; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;';
resetBtn.addEventListener('click', () => {
cy.elements().removeClass('highlight dimmed');
cy.fit(null, 30);
});
toolbar.appendChild(resetBtn);
// Export PNG button
const pngBtn = document.createElement('button');
pngBtn.textContent = 'Export PNG';
pngBtn.style.cssText = 'background: #22c55e; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;';
pngBtn.addEventListener('click', () => {
const dataUrl = cy.png({ bg: '#0f1115', full: true, scale: 2 });
const a = document.createElement('a');
a.href = dataUrl;
a.download = `${containerId}-twins-graph.png`;
a.click();
});
toolbar.appendChild(pngBtn);
// Insert toolbar into container
container.style.position = 'relative';
container.insertBefore(toolbar, container.firstChild);
}
})();
</script><script>/**
* Crowds Graph Visualization
*
* Visualizes files with similar naming patterns that may indicate fragmentation or duplication.
* - Nodes = files grouped by crowd pattern
* - Node color = based on severity (green/orange/red) and issue types
* - Clusters = visual grouping showing which files belong to same pattern
* - Node size = based on importer count (usage)
* - Edges = similarity connections between files in same crowd
*
* Interactive features:
* - Hover on node → tooltip with file info, match reason, issues
* - Hover on edge → tooltip with similarity score
* - Click on node → highlight entire crowd
* - Double-click → open in editor (loctree:// URL)
* - Filter by severity/issue type
*/
(function() {
/**
* Builds and renders the Crowds Graph using Cytoscape.js
*
* @param {Array} crowdsData - Array of crowd objects
* @param {string} containerId - ID of the container element
* @param {string} [openBase] - Base URL for opening files in editor (optional)
*/
window.buildCrowdsGraph = function(crowdsData, containerId, openBase) {
const container = document.getElementById(containerId);
if (!container) {
console.error(`Container ${containerId} not found`);
return null;
}
// Process crowds data to build graph structure
const { nodes, edges, stats } = processCrowdsData(crowdsData);
// Create Cytoscape instance with clustering visualization
const cy = cytoscape({
container: container,
elements: { nodes, edges },
style: getCrowdsGraphStyle(stats.maxImporters, stats.maxSimilarity),
layout: {
name: 'cose',
animate: true,
animationDuration: 800,
animationEasing: 'ease-out-cubic',
fit: true,
padding: 60,
nodeRepulsion: function(node) {
// More repulsion between different crowds
const isCrowdLabel = node.data('isCrowdLabel');
return isCrowdLabel ? 8000 : 3000;
},
idealEdgeLength: function(edge) {
// Shorter edges within same crowd
return edge.data('withinCrowd') ? 80 : 200;
},
edgeElasticity: function(edge) {
// Stronger connections within crowd
return edge.data('withinCrowd') ? 150 : 50;
},
gravity: 0.8,
numIter: 1500,
initialTemp: 1000,
coolingFactor: 0.95,
minTemp: 1.0,
},
minZoom: 0.3,
maxZoom: 3,
wheelSensitivity: 0.2,
});
// Add interactive features
setupInteractivity(cy, openBase, stats, crowdsData);
// Add toolbar controls
setupToolbar(cy, container, containerId, stats, crowdsData);
return cy;
};
/**
* Process crowds data into Cytoscape-compatible nodes and edges
*/
function processCrowdsData(crowdsData) {
const nodes = [];
const edges = [];
let maxImporters = 0;
let maxSimilarity = 0;
let totalFiles = 0;
let totalIssues = 0;
// Process each crowd
crowdsData.forEach((crowd, crowdIndex) => {
const crowdId = `crowd-${crowdIndex}`;
const pattern = crowd.pattern;
const score = crowd.score || 0;
const issues = crowd.issues || [];
totalIssues += issues.length;
// Add a central "crowd label" node for visual clustering (optional, can be hidden)
nodes.push({
data: {
id: crowdId,
label: pattern,
pattern: pattern,
crowdIndex: crowdIndex,
score: score,
memberCount: crowd.members.length,
issues: issues,
isCrowdLabel: true,
importerCount: 0,
}
});
// Process each member in the crowd
crowd.members.forEach((member, memberIndex) => {
const memberId = `${crowdId}-member-${memberIndex}`;
const file = member.file;
const importerCount = member.importer_count || 0;
maxImporters = Math.max(maxImporters, importerCount);
totalFiles++;
nodes.push({
data: {
id: memberId,
label: getFileLabel(file),
fullPath: file,
pattern: pattern,
crowdIndex: crowdIndex,
score: score,
matchReason: member.match_reason,
importerCount: importerCount,
issues: issues,
similarityScores: member.similarity_scores || [],
isCrowdLabel: false,
}
});
// Connect member to crowd label (for visual clustering)
edges.push({
data: {
id: `${crowdId}-to-${memberId}`,
source: crowdId,
target: memberId,
withinCrowd: true,
similarity: 1.0,
}
});
// Add similarity edges between members within same crowd
member.similarity_scores.forEach(([otherFile, similarity]) => {
// Find the other member's ID
const otherMemberIndex = crowd.members.findIndex(m => m.file === otherFile);
if (otherMemberIndex !== -1 && otherMemberIndex > memberIndex) {
const otherMemberId = `${crowdId}-member-${otherMemberIndex}`;
maxSimilarity = Math.max(maxSimilarity, similarity);
edges.push({
data: {
id: `${memberId}-to-${otherMemberId}`,
source: memberId,
target: otherMemberId,
withinCrowd: true,
similarity: similarity,
}
});
}
});
});
});
const stats = {
maxImporters,
maxSimilarity: maxSimilarity > 0 ? maxSimilarity : 1,
totalCrowds: crowdsData.length,
totalFiles,
totalIssues,
averageScore: crowdsData.reduce((sum, c) => sum + (c.score || 0), 0) / crowdsData.length,
};
return { nodes, edges, stats };
}
/**
* Get a shortened label for a file path
*/
function getFileLabel(filePath) {
const parts = filePath.split('/');
if (parts.length <= 2) return filePath;
// Show last 2 parts: "dir/file.ts"
return parts.slice(-2).join('/');
}
/**
* Generate Cytoscape style with dynamic gradients and clustering
*/
function getCrowdsGraphStyle(maxImporters, maxSimilarity) {
return [
// Crowd label nodes (central hubs)
{
selector: 'node[isCrowdLabel = true]',
style: {
'label': 'data(label)',
'font-size': 14,
'font-weight': 'bold',
'text-valign': 'center',
'text-halign': 'center',
'color': '#fff',
'text-outline-color': '#000',
'text-outline-width': 3,
'background-color': function(ele) {
return getSeverityColor(ele.data('score'));
},
'width': function(ele) {
const memberCount = ele.data('memberCount') || 1;
return Math.max(40, Math.min(100, 40 + memberCount * 5));
},
'height': function(ele) {
const memberCount = ele.data('memberCount') || 1;
return Math.max(40, Math.min(100, 40 + memberCount * 5));
},
'shape': 'hexagon',
'border-width': 4,
'border-color': '#fff',
'border-opacity': 0.6,
'opacity': 0.9,
'z-index': 10,
}
},
// Member nodes (files in crowd)
{
selector: 'node[isCrowdLabel = false]',
style: {
'label': 'data(label)',
'font-size': 10,
'font-weight': 'normal',
'text-wrap': 'wrap',
'text-max-width': 120,
'text-valign': 'center',
'text-halign': 'center',
'color': '#fff',
'text-outline-color': '#000',
'text-outline-width': 2,
'background-color': function(ele) {
return getSeverityColor(ele.data('score'));
},
'width': function(ele) {
// Size based on importer count (usage)
const importers = ele.data('importerCount') || 0;
return Math.max(25, Math.min(70, 25 + importers * 2));
},
'height': function(ele) {
const importers = ele.data('importerCount') || 0;
return Math.max(25, Math.min(70, 25 + importers * 2));
},
'shape': 'ellipse',
'border-width': 2,
'border-color': function(ele) {
const issues = ele.data('issues') || [];
return getIssueColor(issues);
},
'border-opacity': 0.8,
'transition-property': 'background-color, border-color, border-width',
'transition-duration': '0.3s',
'overlay-padding': 8,
'overlay-opacity': 0,
}
},
// Highlighted node
{
selector: 'node.highlight',
style: {
'border-width': 5,
'border-color': '#ffd700',
'border-opacity': 1,
'shadow-blur': 20,
'shadow-color': '#ffd700',
'shadow-opacity': 0.8,
'shadow-offset-x': 0,
'shadow-offset-y': 0,
'z-index': 999,
}
},
// Dimmed node
{
selector: 'node.dimmed',
style: {
'opacity': 0.15,
}
},
// Hidden node (for filtering)
{
selector: 'node.filtered-out',
style: {
'display': 'none',
}
},
// Edges connecting crowd label to members (light, structural)
{
selector: 'edge[withinCrowd = true]',
style: {
'curve-style': 'straight',
'width': function(ele) {
const similarity = ele.data('similarity') || 0;
return similarity === 1.0 ? 1 : Math.max(1, similarity * 4);
},
'line-color': function(ele) {
const similarity = ele.data('similarity') || 0;
return similarity === 1.0 ? 'rgba(255, 255, 255, 0.1)' : getSimilarityColor(similarity);
},
'opacity': function(ele) {
const similarity = ele.data('similarity') || 0;
return similarity === 1.0 ? 0.15 : 0.5;
},
'target-arrow-shape': 'none',
'label': '',
'transition-property': 'width, line-color, opacity',
'transition-duration': '0.3s',
}
},
// Highlighted edge
{
selector: 'edge.highlight',
style: {
'width': function(ele) {
const similarity = ele.data('similarity') || 0;
return similarity === 1.0 ? 2 : Math.max(3, similarity * 6);
},
'opacity': 1,
'z-index': 998,
'label': function(ele) {
const similarity = ele.data('similarity') || 0;
return similarity !== 1.0 ? `${(similarity * 100).toFixed(0)}%` : '';
},
'font-size': 9,
'text-background-color': '#000',
'text-background-opacity': 0.7,
'text-background-padding': 3,
'color': '#fff',
}
},
// Dimmed edge
{
selector: 'edge.dimmed',
style: {
'opacity': 0.05,
}
},
// Hidden edge (for filtering)
{
selector: 'edge.filtered-out',
style: {
'display': 'none',
}
},
];
}
/**
* Get color based on severity score (green -> orange -> red)
*/
function getSeverityColor(score) {
if (score < 4.0) {
// Low severity: green to yellow-green
const t = score / 4.0;
return interpolateColor('#27ae60', '#95c56e', t);
} else if (score < 7.0) {
// Medium severity: yellow-orange to orange
const t = (score - 4.0) / 3.0;
return interpolateColor('#e67e22', '#d35400', t);
} else {
// High severity: red to dark red
const t = Math.min((score - 7.0) / 3.0, 1);
return interpolateColor('#c0392b', '#8b0000', t);
}
}
/**
* Get border color based on issue types
*/
function getIssueColor(issues) {
if (!issues || issues.length === 0) return '#4a90e2';
// Priority: NameCollision > UsageAsymmetry > ExportOverlap > Fragmentation
for (const issue of issues) {
if (issue.NameCollision) return '#e74c3c';
if (issue.UsageAsymmetry) return '#e67e22';
if (issue.ExportOverlap) return '#f39c12';
if (issue.Fragmentation) return '#3498db';
}
return '#4a90e2';
}
/**
* Get edge color based on similarity score
*/
function getSimilarityColor(similarity) {
// Normalize to 0-1 range
const ratio = Math.min(Math.max(similarity, 0), 1);
// Color gradient: light blue -> purple -> magenta
if (ratio < 0.5) {
const t = ratio / 0.5;
return interpolateColor('#60a5fa', '#a855f7', t); // blue to purple
} else {
const t = (ratio - 0.5) / 0.5;
return interpolateColor('#a855f7', '#ec4899', t); // purple to magenta
}
}
/**
* Interpolate between two hex colors
*/
function interpolateColor(color1, color2, t) {
const c1 = hexToRgb(color1);
const c2 = hexToRgb(color2);
const r = Math.round(c1.r + (c2.r - c1.r) * t);
const g = Math.round(c1.g + (c2.g - c1.g) * t);
const b = Math.round(c1.b + (c2.b - c1.b) * t);
return rgbToHex(r, g, b);
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
/**
* Setup interactive features (hover, click, double-click)
*/
function setupInteractivity(cy, openBase, stats, crowdsData) {
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'crowds-graph-tooltip';
tooltip.style.cssText = `
position: fixed;
pointer-events: none;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
display: none;
z-index: 10000;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 400px;
backdrop-filter: blur(10px);
`;
document.body.appendChild(tooltip);
let nodeHoverTimeout = null;
let edgeHoverTimeout = null;
// Node hover - show info
cy.on('mouseover', 'node', function(evt) {
const node = evt.target;
const data = node.data();
clearTimeout(nodeHoverTimeout);
nodeHoverTimeout = setTimeout(() => {
tooltip.innerHTML = ''; // Clear previous
if (data.isCrowdLabel) {
// Crowd label tooltip
const patternDiv = document.createElement('div');
patternDiv.style.cssText = 'font-weight: bold; margin-bottom: 8px; color: #60a5fa; font-size: 14px;';
patternDiv.textContent = `Pattern: "${data.pattern}"`;
tooltip.appendChild(patternDiv);
const statsDiv = document.createElement('div');
statsDiv.style.cssText = 'margin-bottom: 8px; font-size: 11px; opacity: 0.9;';
statsDiv.innerHTML = `
<div>Files: ${data.memberCount}</div>
<div>Severity: ${data.score.toFixed(1)}/10</div>
<div>Issues: ${data.issues.length}</div>
`;
tooltip.appendChild(statsDiv);
if (data.issues.length > 0) {
const issuesTitle = document.createElement('div');
issuesTitle.style.cssText = 'font-weight: bold; margin-top: 8px; color: #f39c12;';
issuesTitle.textContent = 'Issues:';
tooltip.appendChild(issuesTitle);
const issuesList = document.createElement('ul');
issuesList.style.cssText = 'margin: 4px 0 0 0; padding-left: 20px; font-size: 10px;';
data.issues.forEach(issue => {
const li = document.createElement('li');
li.style.cssText = 'margin: 2px 0;';
li.textContent = formatIssueType(issue);
issuesList.appendChild(li);
});
tooltip.appendChild(issuesList);
}
} else {
// File node tooltip
const pathDiv = document.createElement('div');
pathDiv.style.cssText = 'font-weight: bold; margin-bottom: 8px; color: #60a5fa;';
pathDiv.textContent = data.fullPath;
tooltip.appendChild(pathDiv);
const statsDiv = document.createElement('div');
statsDiv.style.cssText = 'margin-bottom: 8px; font-size: 11px; opacity: 0.9;';
statsDiv.innerHTML = `
<div>Pattern: ${data.pattern}</div>
<div>Importers: ${data.importerCount}</div>
<div>Severity: ${data.score.toFixed(1)}/10</div>
`;
tooltip.appendChild(statsDiv);
// Match reason
if (data.matchReason) {
const reasonDiv = document.createElement('div');
reasonDiv.style.cssText = 'margin-top: 8px; font-size: 11px; color: #95c56e;';
reasonDiv.innerHTML = `<strong>Match:</strong> ${formatMatchReason(data.matchReason)}`;
tooltip.appendChild(reasonDiv);
}
// Issues
if (data.issues && data.issues.length > 0) {
const issuesDiv = document.createElement('div');
issuesDiv.style.cssText = 'margin-top: 8px; font-size: 10px; color: #f39c12;';
issuesDiv.innerHTML = `<strong>Issues:</strong> ${data.issues.map(formatIssueType).join(', ')}`;
tooltip.appendChild(issuesDiv);
}
// Open in editor hint
if (openBase) {
const hint = document.createElement('div');
hint.style.cssText = 'margin-top: 8px; font-size: 10px; opacity: 0.7; font-style: italic;';
hint.textContent = 'Double-click to open in editor';
tooltip.appendChild(hint);
}
}
tooltip.style.display = 'block';
positionTooltip(evt.renderedPosition);
}, 100);
});
cy.on('mouseout', 'node', function() {
clearTimeout(nodeHoverTimeout);
tooltip.style.display = 'none';
});
// Edge hover - show similarity
cy.on('mouseover', 'edge', function(evt) {
const edge = evt.target;
const data = edge.data();
if (data.similarity === 1.0) return; // Skip structural edges
clearTimeout(edgeHoverTimeout);
edgeHoverTimeout = setTimeout(() => {
tooltip.innerHTML = '';
const titleDiv = document.createElement('div');
titleDiv.style.cssText = 'font-weight: bold; color: #a855f7;';
titleDiv.textContent = `Similarity: ${(data.similarity * 100).toFixed(0)}%`;
tooltip.appendChild(titleDiv);
tooltip.style.display = 'block';
positionTooltip(evt.renderedPosition);
}, 100);
});
cy.on('mouseout', 'edge', function() {
clearTimeout(edgeHoverTimeout);
tooltip.style.display = 'none';
});
// Click on node - highlight crowd
cy.on('tap', 'node', function(evt) {
const node = evt.target;
const crowdIndex = node.data('crowdIndex');
// Clear previous highlights
cy.elements().removeClass('highlight dimmed');
// Highlight entire crowd
const crowdNodes = cy.nodes().filter(n => n.data('crowdIndex') === crowdIndex);
const crowdEdges = cy.edges().filter(e => {
const sourceIndex = e.source().data('crowdIndex');
const targetIndex = e.target().data('crowdIndex');
return sourceIndex === crowdIndex && targetIndex === crowdIndex;
});
crowdNodes.addClass('highlight');
crowdEdges.addClass('highlight');
// Dim everything else
cy.elements().not(crowdNodes.union(crowdEdges)).addClass('dimmed');
});
// Click on background - clear highlights
cy.on('tap', function(evt) {
if (evt.target === cy) {
cy.elements().removeClass('highlight dimmed');
}
});
// Double-click on file node - open in editor
if (openBase) {
cy.on('dbltap', 'node[isCrowdLabel = false]', function(evt) {
const node = evt.target;
const filePath = node.data('fullPath');
const url = `${openBase}/open?f=${encodeURIComponent(filePath)}&l=1`;
window.open(url, '_blank');
});
}
function positionTooltip(renderedPos) {
const containerRect = cy.container().getBoundingClientRect();
let left = containerRect.left + renderedPos.x + 15;
let top = containerRect.top + renderedPos.y + 15;
// Keep tooltip within viewport
const tooltipRect = tooltip.getBoundingClientRect();
const maxLeft = window.innerWidth - tooltipRect.width - 10;
const maxTop = window.innerHeight - tooltipRect.height - 10;
if (left > maxLeft) left = Math.max(10, renderedPos.x - tooltipRect.width - 15);
if (top > maxTop) top = Math.max(10, renderedPos.y - tooltipRect.height - 15);
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
}
/**
* Format issue type for display
*/
function formatIssueType(issue) {
if (issue.NameCollision) {
return `Name Collision (${issue.NameCollision.files.length} files)`;
}
if (issue.UsageAsymmetry) {
return `Usage Asymmetry`;
}
if (issue.ExportOverlap) {
return `Export Overlap (${issue.ExportOverlap.overlap.length} symbols)`;
}
if (issue.Fragmentation) {
return `Fragmentation (${issue.Fragmentation.categories.length} categories)`;
}
return 'Unknown issue';
}
/**
* Format match reason for display
*/
function formatMatchReason(reason) {
if (reason.NameMatch) {
return `Name: ${reason.NameMatch.matched}`;
}
if (reason.ImportSimilarity) {
return `Import similarity (${(reason.ImportSimilarity.similarity * 100).toFixed(0)}%)`;
}
if (reason.ExportSimilarity) {
return `Similar exports to ${reason.ExportSimilarity.similar_to}`;
}
return 'Unknown';
}
/**
* Setup toolbar with controls
*/
function setupToolbar(cy, container, containerId, stats, crowdsData) {
// Create toolbar
const toolbar = document.createElement('div');
toolbar.className = 'crowds-graph-toolbar';
toolbar.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
padding: 12px 16px;
border-radius: 8px;
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
font-size: 12px;
color: #fff;
`;
// Stats display
const statsDiv = document.createElement('div');
statsDiv.style.cssText = 'display: flex; gap: 16px; margin-right: auto; flex-wrap: wrap;';
statsDiv.innerHTML = `
<span><strong>Crowds:</strong> ${stats.totalCrowds}</span>
<span><strong>Files:</strong> ${stats.totalFiles}</span>
<span><strong>Issues:</strong> ${stats.totalIssues}</span>
<span><strong>Avg Severity:</strong> ${stats.averageScore.toFixed(1)}</span>
`;
toolbar.appendChild(statsDiv);
// Severity filter
const severityLabel = document.createElement('label');
severityLabel.style.cssText = 'display: flex; gap: 6px; align-items: center;';
severityLabel.innerHTML = '<span>Min Severity:</span>';
const severitySelect = document.createElement('select');
severitySelect.style.cssText = 'background: #1a1a2e; color: #fff; border: 1px solid #444; padding: 4px 8px; border-radius: 4px;';
severitySelect.innerHTML = `
<option value="0">All (0+)</option>
<option value="4">Medium (4+)</option>
<option value="7">High (7+)</option>
`;
severityLabel.appendChild(severitySelect);
toolbar.appendChild(severityLabel);
severitySelect.addEventListener('change', () => {
const minSeverity = parseFloat(severitySelect.value);
cy.nodes().forEach(node => {
const score = node.data('score') || 0;
if (score < minSeverity) {
node.addClass('filtered-out');
node.connectedEdges().addClass('filtered-out');
} else {
node.removeClass('filtered-out');
node.connectedEdges().removeClass('filtered-out');
}
});
cy.fit(cy.elements(':visible'), 30);
});
// Layout selector
const layoutLabel = document.createElement('label');
layoutLabel.style.cssText = 'display: flex; gap: 6px; align-items: center;';
layoutLabel.innerHTML = '<span>Layout:</span>';
const layoutSelect = document.createElement('select');
layoutSelect.style.cssText = 'background: #1a1a2e; color: #fff; border: 1px solid #444; padding: 4px 8px; border-radius: 4px;';
layoutSelect.innerHTML = `
<option value="cose">Force (COSE)</option>
<option value="concentric">Concentric</option>
<option value="circle">Circle</option>
<option value="grid">Grid</option>
`;
layoutLabel.appendChild(layoutSelect);
toolbar.appendChild(layoutLabel);
layoutSelect.addEventListener('change', () => {
const layoutName = layoutSelect.value;
cy.layout({
name: layoutName,
animate: true,
animationDuration: 600,
fit: true,
padding: 50,
}).run();
});
// Fit button
const fitBtn = document.createElement('button');
fitBtn.textContent = 'Fit';
fitBtn.style.cssText = 'background: #4a90e2; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;';
fitBtn.addEventListener('click', () => cy.fit(cy.elements(':visible'), 30));
toolbar.appendChild(fitBtn);
// Reset button
const resetBtn = document.createElement('button');
resetBtn.textContent = 'Reset';
resetBtn.style.cssText = 'background: #666; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;';
resetBtn.addEventListener('click', () => {
cy.elements().removeClass('highlight dimmed filtered-out');
severitySelect.value = '0';
cy.fit(null, 30);
});
toolbar.appendChild(resetBtn);
// Export PNG button
const pngBtn = document.createElement('button');
pngBtn.textContent = 'Export PNG';
pngBtn.style.cssText = 'background: #22c55e; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;';
pngBtn.addEventListener('click', () => {
const dataUrl = cy.png({ bg: '#0f1115', full: true, scale: 2 });
const a = document.createElement('a');
a.href = dataUrl;
a.download = `${containerId}-crowds-graph.png`;
a.click();
});
toolbar.appendChild(pngBtn);
// Insert toolbar into container
container.style.position = 'relative';
container.insertBefore(toolbar, container.firstChild);
}
})();
</script></body></html>