forked from snxraven/p2ns
reorg
This commit is contained in:
@@ -0,0 +1,996 @@
|
||||
:root {
|
||||
/* Modern Color Palette */
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--primary-light: #818cf8;
|
||||
--secondary: #8b5cf6;
|
||||
--accent: #ec4899;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
|
||||
/* Background Colors - Darker for glass effect */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #0f1419;
|
||||
--bg-tertiary: #1a1f2e;
|
||||
--bg-glass: rgba(255, 255, 255, 0.03);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-glass-strong: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
|
||||
/* Borders & Shadows - Enhanced for glass */
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-color-strong: rgba(255, 255, 255, 0.15);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 30px rgba(99, 102, 241, 0.4);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #0a0e1a 0%, #0f1419 50%, #1a1f2e 100%);
|
||||
background-attachment: fixed;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing-lg) - 20px) var(--spacing-xl);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(10, 14, 26, 0.7);
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.header > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--success);
|
||||
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background: var(--error);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected::before {
|
||||
background: var(--error);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* Main Content Layout */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 16rem;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sidebar > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section:last-child {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sidebar-section h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* View Tabs */
|
||||
.view-tab {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-base);
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.view-tab:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--border-color-strong);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.view-tab.active {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.view-tab.active:hover {
|
||||
background: rgba(99, 102, 241, 0.5);
|
||||
border-color: rgba(99, 102, 241, 0.7);
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.control-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.control-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.control-input,
|
||||
.control-select {
|
||||
width: 100%;
|
||||
background: rgba(15, 20, 30, 0.6);
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-base);
|
||||
font-family: inherit;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.control-input:focus,
|
||||
.control-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
background: rgba(15, 20, 30, 0.8);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2), var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.control-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.control-checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.control-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.control-checkbox input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.control-checkbox input[type="checkbox"]:checked {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* View Content */
|
||||
.view-content {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.view-content.active {
|
||||
display: flex !important;
|
||||
visibility: visible;
|
||||
flex-direction: column;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Main Visualization Area */
|
||||
.main-viz-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* SVG */
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Vis-network Graph Containers */
|
||||
#graphContainer,
|
||||
#domainMapContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Vis-network styling overrides */
|
||||
.vis-network {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vis-network canvas {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Vis-network tooltip styling */
|
||||
.vis-tooltip {
|
||||
background: var(--bg-glass) !important;
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
color: var(--text-primary) !important;
|
||||
padding: var(--spacing-sm) var(--spacing-md) !important;
|
||||
font-size: 0.875rem !important;
|
||||
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
/* Vis-network node selection */
|
||||
.vis-network .vis-selected {
|
||||
box-shadow: 0 0 10px var(--primary) !important;
|
||||
}
|
||||
|
||||
/* Vis-network navigation controls */
|
||||
.vis-network .vis-navigation {
|
||||
background: var(--bg-glass) !important;
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.03) !important;
|
||||
}
|
||||
|
||||
.vis-network .vis-navigation .vis-button {
|
||||
background: rgba(15, 20, 30, 0.6) !important;
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03) !important;
|
||||
}
|
||||
|
||||
.vis-network .vis-navigation .vis-button:hover {
|
||||
background: var(--bg-glass-hover) !important;
|
||||
border-color: var(--border-color-strong) !important;
|
||||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* System Overview */
|
||||
.system-overview-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--spacing-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.metrics-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.metrics-grid-centered {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
max-width: calc(66.666% + var(--spacing-md));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: all var(--transition-base);
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.metric-card h3 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-card .metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-card .metric-value.primary {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.metric-card .metric-value.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.metric-card .metric-value.secondary {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.metric-card .metric-value.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.chart-card h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
canvas {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Chart.js canvas containers - keep fixed height for charts */
|
||||
.chart-container canvas {
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
/* Peer Details View */
|
||||
.peer-details-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--spacing-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.peer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.peer-item:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
transform: translateX(4px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.peer-item.connected {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.peer-item.disconnected {
|
||||
border-color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Peer Details Panel */
|
||||
#peerDetailsPanel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 24rem;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border-left: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-xl);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#peerDetailsPanel:not(.hidden) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#peerDetailsPanel.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.peer-details-header {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.peer-details-header h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
padding: var(--spacing-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
#peerDetailsContent {
|
||||
padding: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#peerDetailsContent h4 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
#peerDetailsContent p {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 4px solid var(--bg-tertiary);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Dynamic content styling */
|
||||
.avatar-placeholder-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.avatar-placeholder-circle:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.avatar-image:hover {
|
||||
box-shadow: var(--shadow-glow);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.peer-name-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.peer-name-primary {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.peer-name-secondary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.peer-status-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.peer-uptime-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.details-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.details-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.details-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.details-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.details-item h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.details-item p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.details-item .details-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.details-metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.details-metrics p {
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
margin-top: var(--spacing-sm);
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#peerDetailsPanel {
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" width="576" height="512">
|
||||
<path fill="#3b82f6" d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l51 76.5c6.2 9.3 16.9 15.2 28.1 15.2h60.1l30.3 60.6c-36.1 18.1-60.3 54.2-60.3 95.1c0 59.2 47.9 107.1 107.1 107.1c59.2 0 107.1-47.9 107.1-107.1c0-40.9-24.2-77-60.3-95.1L240.4 170h60.1c11.2 0 21.9-5.9 28.1-15.2l51-76.5c7.3-9.6 6.4-23.1-2.1-31.6L337.8 7c-8.5-8.5-22-9.4-31.6-2.1l-76.5 51c-9.3 6.2-15.2 16.9-15.2 28.1v60.1l-60.6 30.3C95.1 136.2 59 160 18.1 160C8.6 160 0 168.6 0 178.1s8.6 18.1 18.1 18.1c59.2 0 107.1-47.9 107.1-107.1c0-40.9-24.2-77-60.3-95.1L184.4 82V21.9c0-11.2 5.9-21.9 15.2-28.1L276.1-57.1zM576 80a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM288 176a48 48 0 1 0 0 96 48 48 0 1 0 0-96zM448 336a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm-224 32a48 48 0 1 0 0 96 48 48 0 1 0 0-96zM576 400a48 48 0 1 0 -96 0 48 48 0 1 0 96 0z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 909 B |
@@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<title>P2NS Network Visualization</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="/css/tailwind.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://global.profile/css/profile-modal.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/vis-network@latest/styles/vis-network.min.css">
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
<script src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>P2NS Network Visualization</h1>
|
||||
<p class="subtitle">Visualize and explore the P2NS network</p>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<div class="status-indicator" id="statusIndicator" aria-label="Connection status"></div>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<!-- View Tabs -->
|
||||
<div class="sidebar-section">
|
||||
<h2>Views</h2>
|
||||
<div>
|
||||
<a href="#overview" class="view-tab active" data-view="system-overview">System Overview</a>
|
||||
<a href="#network-graph" class="view-tab" data-view="network-graph">Network Graph</a>
|
||||
<a href="#peer-details" class="view-tab" data-view="peer-details">Peer Details</a>
|
||||
<a href="#domain-map" class="view-tab" data-view="domain-map">Domain Map</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="sidebar-section" style="overflow-y: auto; overflow-x: hidden; min-height: 0; flex: 1;">
|
||||
<h2>Controls</h2>
|
||||
|
||||
<!-- Layout Options -->
|
||||
<div class="control-group">
|
||||
<label class="control-label" for="layoutSelect">Layout</label>
|
||||
<select id="layoutSelect" class="control-select">
|
||||
<option value="force">Force-Directed</option>
|
||||
<option value="hierarchical">Hierarchical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="control-group">
|
||||
<label class="control-label">Filters</label>
|
||||
<div class="control-checkbox-group">
|
||||
<label class="control-checkbox">
|
||||
<input type="checkbox" id="filterConnected" checked>
|
||||
<span>Connected Peers</span>
|
||||
</label>
|
||||
<label class="control-checkbox">
|
||||
<input type="checkbox" id="filterDomains" checked>
|
||||
<span>Domains</span>
|
||||
</label>
|
||||
<label id="filterOfflinePeersLabel" class="control-checkbox hidden">
|
||||
<input type="checkbox" id="filterOfflinePeers">
|
||||
<span>Show Offline Peers</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="control-group">
|
||||
<label class="control-label" for="searchInput">Search</label>
|
||||
<input type="text" id="searchInput" placeholder="Search peers/domains..." class="control-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="sidebar-section">
|
||||
<h2>Stats</h2>
|
||||
<div class="stats-list">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Peers Seen:</span>
|
||||
<span id="statPeers" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Domains:</span>
|
||||
<span id="statDomains" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Connections:</span>
|
||||
<span id="statConnections" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Visualization Area -->
|
||||
<main class="main-viz-area">
|
||||
<!-- View Container -->
|
||||
<div id="viewContainer" class="flex-1 relative">
|
||||
<!-- System Overview View (Default) -->
|
||||
<div id="system-overview-view" class="view-content active">
|
||||
<div class="system-overview-content">
|
||||
<div class="metrics-container">
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<h3>Total Peers Seen</h3>
|
||||
<p class="metric-value primary" id="metricTotalPeers">0</p>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<h3>Connected Peers</h3>
|
||||
<p class="metric-value success" id="metricConnectedPeers">0</p>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<h3>Total Domains</h3>
|
||||
<p class="metric-value secondary" id="metricTotalDomains">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics-grid-centered">
|
||||
<div class="metric-card">
|
||||
<h3>System Uptime</h3>
|
||||
<p class="metric-value" id="metricUptime">--</p>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<h3>Active Connections</h3>
|
||||
<p class="metric-value warning" id="metricActiveConnections">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>Peer Connection Timeline</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="timelineChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>Connection Duration Distribution</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="durationChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Graph View -->
|
||||
<div id="network-graph-view" class="view-content">
|
||||
<div id="graphContainer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Peer Details View -->
|
||||
<div id="peer-details-view" class="view-content">
|
||||
<div class="peer-details-content">
|
||||
<div id="peerList" class="peer-list">
|
||||
<!-- Peer items will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Map View -->
|
||||
<div id="domain-map-view" class="view-content">
|
||||
<div id="domainMapContainer"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Peer Details Panel (Sidebar) -->
|
||||
<div id="peerDetailsPanel" class="hidden">
|
||||
<div class="peer-details-header">
|
||||
<h3>Peer Details</h3>
|
||||
<button id="closePeerDetails" class="close-btn" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div id="peerDetailsContent">
|
||||
<!-- Peer details will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Modal Component -->
|
||||
<div id="profile-modal" class="profile-modal" style="display: none;">
|
||||
<div class="profile-modal-backdrop"></div>
|
||||
<div class="profile-modal-content">
|
||||
<button class="profile-modal-close" aria-label="Close" onclick="if(window.ProfileModal){window.ProfileModal.close();}">×</button>
|
||||
|
||||
<div class="profile-modal-loading" id="profile-modal-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading profile...</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-error" id="profile-modal-error" style="display: none;">
|
||||
<p>Failed to load profile</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-body" id="profile-modal-body" style="display: none;">
|
||||
<div class="profile-modal-header">
|
||||
<div class="profile-modal-avatar-container">
|
||||
<img id="profile-modal-avatar" class="profile-modal-avatar" alt="Avatar" onerror="this.style.display='none'; document.getElementById('profile-modal-avatar-placeholder').style.display='flex';">
|
||||
<div id="profile-modal-avatar-placeholder" class="profile-modal-avatar-placeholder" style="display: none;">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-modal-header-info">
|
||||
<h2 id="profile-modal-name" class="profile-modal-name">Loading...</h2>
|
||||
<p id="profile-modal-peerid" class="profile-modal-peerid">...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-bio" id="profile-modal-bio-container" style="display: none;">
|
||||
<p id="profile-modal-bio" class="profile-modal-bio-text"></p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-details" id="profile-modal-details">
|
||||
<div class="profile-modal-detail-item" id="profile-modal-email-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">📧</span>
|
||||
<a id="profile-modal-email" href="#" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-website-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">🌐</span>
|
||||
<a id="profile-modal-website" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-x-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">🐦</span>
|
||||
<a id="profile-modal-x" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-actions" id="profile-modal-actions" style="display: none;">
|
||||
<button id="profile-modal-block-btn" class="profile-modal-action-btn profile-modal-action-btn-block" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
|
||||
</svg>
|
||||
Block
|
||||
</button>
|
||||
<button id="profile-modal-unblock-btn" class="profile-modal-action-btn profile-modal-action-btn-unblock" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
Unblock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/data-processor.js"></script>
|
||||
<script src="/js/websocket-client.js"></script>
|
||||
<script src="https://global.profile/js/profile-modal.js"></script>
|
||||
<script src="/js/visualization/dashboard.js"></script>
|
||||
<script src="/js/visualization/peer-details.js"></script>
|
||||
<script src="/js/visualization/network-graph.js"></script>
|
||||
<script src="/js/visualization/domain-map.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* Main application logic for peer.visualize
|
||||
*/
|
||||
|
||||
// Global state
|
||||
let currentView = 'system-overview'; // Default to overview
|
||||
let currentData = null;
|
||||
let visualization = null;
|
||||
|
||||
/**
|
||||
* Initialize application
|
||||
*/
|
||||
function init() {
|
||||
// Initialize ProfileModal if available
|
||||
if (window.ProfileModal && typeof window.ProfileModal.init === 'function') {
|
||||
window.ProfileModal.init();
|
||||
}
|
||||
|
||||
// Setup hash-based view switching
|
||||
setupHashNavigation();
|
||||
|
||||
// Setup controls
|
||||
setupControls();
|
||||
|
||||
// Setup WebSocket listeners
|
||||
setupWebSocketListeners();
|
||||
|
||||
// Connect WebSocket
|
||||
wsClient.connect();
|
||||
|
||||
// Load initial data
|
||||
loadInitialData();
|
||||
|
||||
// Set default hash if none exists
|
||||
if (!window.location.hash || window.location.hash === '#') {
|
||||
window.location.hash = '#overview';
|
||||
}
|
||||
|
||||
// Always handle hash change on init to set up the view
|
||||
handleHashChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup hash-based navigation
|
||||
*/
|
||||
function setupHashNavigation() {
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
|
||||
// Also listen for popstate (back/forward buttons)
|
||||
window.addEventListener('popstate', handleHashChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hash change
|
||||
*/
|
||||
function handleHashChange() {
|
||||
const hash = window.location.hash.slice(1) || 'overview'; // Remove # and default to overview
|
||||
|
||||
// Map hash to view name
|
||||
const viewMap = {
|
||||
'overview': 'system-overview',
|
||||
'network-graph': 'network-graph',
|
||||
'peer-details': 'peer-details',
|
||||
'domain-map': 'domain-map'
|
||||
};
|
||||
|
||||
const view = viewMap[hash] || 'system-overview';
|
||||
switchView(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different view
|
||||
*/
|
||||
function switchView(view) {
|
||||
// Destroy previous visualization if it exists
|
||||
if (visualization) {
|
||||
try {
|
||||
if (visualization.destroy && typeof visualization.destroy === 'function') {
|
||||
visualization.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error destroying visualization:', err);
|
||||
}
|
||||
visualization = null;
|
||||
}
|
||||
|
||||
// First, hide ALL views explicitly
|
||||
document.querySelectorAll('.view-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
content.style.display = 'none';
|
||||
content.style.visibility = 'hidden';
|
||||
});
|
||||
|
||||
// Update active tab
|
||||
document.querySelectorAll('.view-tab').forEach(tab => {
|
||||
const tabView = tab.dataset.view;
|
||||
if (tabView === view) {
|
||||
tab.classList.add('active');
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Show only the selected view
|
||||
const targetView = document.getElementById(`${view}-view`);
|
||||
if (targetView) {
|
||||
targetView.classList.add('active');
|
||||
targetView.style.display = 'flex';
|
||||
targetView.style.flexDirection = 'column';
|
||||
targetView.style.visibility = 'visible';
|
||||
currentView = view;
|
||||
|
||||
// Small delay to ensure DOM is updated and view is visible
|
||||
setTimeout(() => {
|
||||
// Initialize view-specific visualizations
|
||||
if (view === 'system-overview') {
|
||||
initSystemOverview();
|
||||
} else if (view === 'network-graph') {
|
||||
const layoutSelect = document.getElementById('layoutSelect');
|
||||
const layout = layoutSelect?.value || 'force';
|
||||
updateFilterVisibility(layout);
|
||||
initNetworkGraph();
|
||||
} else if (view === 'peer-details') {
|
||||
initPeerDetails();
|
||||
} else if (view === 'domain-map') {
|
||||
updateFilterVisibility('force');
|
||||
initDomainMap();
|
||||
}
|
||||
}, 150);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup controls
|
||||
*/
|
||||
function setupControls() {
|
||||
// Layout selector
|
||||
const layoutSelect = document.getElementById('layoutSelect');
|
||||
if (layoutSelect) {
|
||||
layoutSelect.addEventListener('change', (e) => {
|
||||
if (currentView === 'network-graph' || currentView === 'domain-map') {
|
||||
switchLayout(e.target.value);
|
||||
updateFilterVisibility(e.target.value);
|
||||
}
|
||||
});
|
||||
// Initial visibility check
|
||||
if (currentView === 'network-graph' || currentView === 'domain-map') {
|
||||
updateFilterVisibility(layoutSelect.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
const filterConnected = document.getElementById('filterConnected');
|
||||
const filterDomains = document.getElementById('filterDomains');
|
||||
const filterOfflinePeers = document.getElementById('filterOfflinePeers');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
|
||||
if (filterConnected) {
|
||||
filterConnected.addEventListener('change', applyFilters);
|
||||
}
|
||||
if (filterDomains) {
|
||||
filterDomains.addEventListener('change', applyFilters);
|
||||
}
|
||||
if (filterOfflinePeers) {
|
||||
filterOfflinePeers.addEventListener('change', applyFilters);
|
||||
}
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', debounce(applyFilters, 300));
|
||||
}
|
||||
|
||||
// Close peer details panel
|
||||
const closePeerDetails = document.getElementById('closePeerDetails');
|
||||
if (closePeerDetails) {
|
||||
closePeerDetails.addEventListener('click', () => {
|
||||
const panel = document.getElementById('peerDetailsPanel');
|
||||
if (panel) {
|
||||
panel.classList.add('hidden');
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket listeners
|
||||
*/
|
||||
function setupWebSocketListeners() {
|
||||
wsClient.on('connected', () => {
|
||||
wsClient.requestSystem();
|
||||
wsClient.requestTopology();
|
||||
wsClient.requestMetrics();
|
||||
});
|
||||
|
||||
wsClient.on('system-update', (data) => {
|
||||
updateSystemData(data);
|
||||
});
|
||||
|
||||
wsClient.on('topology-update', (data) => {
|
||||
updateTopology(data);
|
||||
});
|
||||
|
||||
wsClient.on('metrics-update', (data) => {
|
||||
updateMetrics(data);
|
||||
});
|
||||
|
||||
wsClient.on('init', (data) => {
|
||||
updateSystemData(data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial data via API
|
||||
*/
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
const response = await fetch('/api/system');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
updateSystemData(data);
|
||||
}
|
||||
|
||||
const topologyResponse = await fetch('/api/topology');
|
||||
if (topologyResponse.ok) {
|
||||
const topology = await topologyResponse.json();
|
||||
updateTopology(topology);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system data
|
||||
*/
|
||||
function updateSystemData(data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentData = data;
|
||||
const processed = dataProcessor.processSystemState(data);
|
||||
|
||||
// Update stats sidebar
|
||||
updateStats(processed);
|
||||
|
||||
// Always update system overview metrics if that view is active
|
||||
if (currentView === 'system-overview') {
|
||||
updateSystemOverview(processed);
|
||||
}
|
||||
|
||||
// Update views if active
|
||||
if (currentView === 'network-graph' && visualization) {
|
||||
// For network-graph view, only update peer properties, don't rebuild graph structure
|
||||
// The graph structure comes from topology-update messages
|
||||
visualization.updatePeerProperties(processed.peers || []);
|
||||
} else if (currentView === 'peer-details') {
|
||||
updatePeerDetailsList(processed.peers);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Update topology
|
||||
*/
|
||||
function updateTopology(data) {
|
||||
const processed = dataProcessor.processTopology(data);
|
||||
|
||||
// Update visualizations if active
|
||||
if (currentView === 'network-graph' && visualization) {
|
||||
visualization.updateTopology(processed);
|
||||
} else if (currentView === 'domain-map' && visualization) {
|
||||
visualization.updateTopology(processed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metrics
|
||||
*/
|
||||
function updateMetrics(data) {
|
||||
if (currentView === 'system-overview') {
|
||||
// Update system overview with metrics data
|
||||
if (currentData) {
|
||||
const processed = dataProcessor.processSystemState(currentData);
|
||||
updateSystemOverview(processed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stats sidebar
|
||||
*/
|
||||
function updateStats(data) {
|
||||
const statPeers = document.getElementById('statPeers');
|
||||
const statDomains = document.getElementById('statDomains');
|
||||
const statConnections = document.getElementById('statConnections');
|
||||
|
||||
if (statPeers) statPeers.textContent = data.peers.length;
|
||||
if (statDomains) statDomains.textContent = data.domains.length;
|
||||
if (statConnections) statConnections.textContent = data.connections.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filter visibility based on layout
|
||||
*/
|
||||
function updateFilterVisibility(layout) {
|
||||
const filterOfflinePeersLabel = document.getElementById('filterOfflinePeersLabel');
|
||||
if (filterOfflinePeersLabel) {
|
||||
// Show offline peers filter only for hierarchical layout
|
||||
if (layout === 'hierarchical') {
|
||||
filterOfflinePeersLabel.classList.remove('hidden');
|
||||
} else {
|
||||
filterOfflinePeersLabel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters
|
||||
*/
|
||||
function applyFilters() {
|
||||
if (!currentData) return;
|
||||
|
||||
const layoutSelect = document.getElementById('layoutSelect');
|
||||
const layout = layoutSelect?.value || 'force';
|
||||
|
||||
const filters = {
|
||||
showConnected: document.getElementById('filterConnected')?.checked ?? true,
|
||||
showDomains: document.getElementById('filterDomains')?.checked ?? true,
|
||||
searchTerm: document.getElementById('searchInput')?.value || ''
|
||||
};
|
||||
|
||||
// Add offline peers filter for hierarchical layout
|
||||
if (layout === 'hierarchical') {
|
||||
filters.showOfflinePeers = document.getElementById('filterOfflinePeers')?.checked ?? false;
|
||||
}
|
||||
|
||||
// Apply filters to visualizations
|
||||
if (visualization && visualization.setFilters) {
|
||||
visualization.setFilters(filters);
|
||||
} else if (visualization && visualization.applyFilters) {
|
||||
visualization.applyFilters(filters);
|
||||
}
|
||||
|
||||
// Apply filters to peer details view
|
||||
if (currentView === 'peer-details') {
|
||||
const processed = dataProcessor.processSystemState(currentData);
|
||||
let filteredPeers = processed.peers || [];
|
||||
|
||||
// Filter by connection status
|
||||
if (!filters.showConnected) {
|
||||
filteredPeers = filteredPeers.filter(p => !p.connected);
|
||||
} else {
|
||||
filteredPeers = filteredPeers.filter(p => p.connected);
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (filters.searchTerm) {
|
||||
const searchLower = filters.searchTerm.toLowerCase();
|
||||
filteredPeers = filteredPeers.filter(p => {
|
||||
const peerId = p.id?.toLowerCase() || '';
|
||||
const displayName = p.profile?.displayName?.toLowerCase() || '';
|
||||
return peerId.includes(searchLower) || displayName.includes(searchLower);
|
||||
});
|
||||
}
|
||||
|
||||
updatePeerDetailsList(filteredPeers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch layout
|
||||
*/
|
||||
function switchLayout(layout) {
|
||||
if (visualization && visualization.setLayout) {
|
||||
visualization.setLayout(layout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize network graph view
|
||||
*/
|
||||
function initNetworkGraph() {
|
||||
const container = document.getElementById('graphContainer');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a bit for container to be properly sized
|
||||
setTimeout(() => {
|
||||
if (typeof NetworkGraph === 'undefined') {
|
||||
console.error('NetworkGraph class not found');
|
||||
return;
|
||||
}
|
||||
|
||||
visualization = new NetworkGraph(container);
|
||||
|
||||
// Set initial layout
|
||||
const layoutSelect = document.getElementById('layoutSelect');
|
||||
const layout = layoutSelect?.value || 'force';
|
||||
visualization.setLayout(layout);
|
||||
|
||||
// Apply initial filters
|
||||
const filters = {
|
||||
showConnected: document.getElementById('filterConnected')?.checked ?? true,
|
||||
showDomains: document.getElementById('filterDomains')?.checked ?? true,
|
||||
searchTerm: document.getElementById('searchInput')?.value || ''
|
||||
};
|
||||
if (layout === 'hierarchical') {
|
||||
filters.showOfflinePeers = document.getElementById('filterOfflinePeers')?.checked ?? false;
|
||||
}
|
||||
visualization.setFilters(filters);
|
||||
|
||||
// Load topology data
|
||||
fetch('/api/topology')
|
||||
.then(response => response.json())
|
||||
.then(topology => {
|
||||
const processed = dataProcessor.processTopology(topology);
|
||||
visualization.updateTopology(processed);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading topology:', err);
|
||||
});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize domain map view
|
||||
*/
|
||||
function initDomainMap() {
|
||||
const container = document.getElementById('domainMapContainer');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a bit for container to be properly sized
|
||||
setTimeout(() => {
|
||||
if (typeof DomainMap === 'undefined') {
|
||||
console.error('DomainMap class not found');
|
||||
return;
|
||||
}
|
||||
|
||||
visualization = new DomainMap(container);
|
||||
|
||||
// Set initial layout
|
||||
const layoutSelect = document.getElementById('layoutSelect');
|
||||
const layout = layoutSelect?.value || 'force';
|
||||
visualization.setLayout(layout);
|
||||
|
||||
// Apply initial filters
|
||||
const filters = {
|
||||
showConnected: document.getElementById('filterConnected')?.checked ?? true,
|
||||
showDomains: document.getElementById('filterDomains')?.checked ?? true,
|
||||
searchTerm: document.getElementById('searchInput')?.value || ''
|
||||
};
|
||||
visualization.setFilters(filters);
|
||||
|
||||
// Load topology data
|
||||
fetch('/api/topology')
|
||||
.then(response => response.json())
|
||||
.then(topology => {
|
||||
const processed = dataProcessor.processTopology(topology);
|
||||
visualization.updateTopology(processed);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading topology:', err);
|
||||
});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize system overview
|
||||
*/
|
||||
function initSystemOverview() {
|
||||
// Update with current data if available
|
||||
if (currentData) {
|
||||
const processed = dataProcessor.processSystemState(currentData);
|
||||
updateSystemOverview(processed);
|
||||
} else {
|
||||
// Load fresh data if not available
|
||||
loadInitialData().then(() => {
|
||||
if (currentData) {
|
||||
const processed = dataProcessor.processSystemState(currentData);
|
||||
updateSystemOverview(processed);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system overview
|
||||
*/
|
||||
function updateSystemOverview(data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = dataProcessor.aggregateMetrics(data);
|
||||
|
||||
// Update metric cards
|
||||
const totalPeersEl = document.getElementById('metricTotalPeers');
|
||||
const connectedPeersEl = document.getElementById('metricConnectedPeers');
|
||||
const totalDomainsEl = document.getElementById('metricTotalDomains');
|
||||
const activeConnectionsEl = document.getElementById('metricActiveConnections');
|
||||
const uptimeEl = document.getElementById('metricUptime');
|
||||
|
||||
if (totalPeersEl) totalPeersEl.textContent = metrics.totalPeers || 0;
|
||||
if (connectedPeersEl) connectedPeersEl.textContent = metrics.connectedPeers || 0;
|
||||
if (totalDomainsEl) totalDomainsEl.textContent = metrics.totalDomains || 0;
|
||||
if (activeConnectionsEl) activeConnectionsEl.textContent = metrics.activeConnections || 0;
|
||||
|
||||
// Calculate system uptime (time since first peer connection or system start)
|
||||
if (uptimeEl && data.peers && data.peers.length > 0) {
|
||||
const peersWithConnectTime = data.peers.filter(p => p && p.connectTime);
|
||||
if (peersWithConnectTime.length > 0) {
|
||||
const oldestConnection = peersWithConnectTime
|
||||
.map(p => p.connectTime)
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (oldestConnection) {
|
||||
const uptime = Date.now() - oldestConnection;
|
||||
uptimeEl.textContent = formatDuration(uptime);
|
||||
} else {
|
||||
uptimeEl.textContent = '--';
|
||||
}
|
||||
} else {
|
||||
uptimeEl.textContent = '--';
|
||||
}
|
||||
} else if (uptimeEl) {
|
||||
uptimeEl.textContent = '--';
|
||||
}
|
||||
|
||||
// Update charts
|
||||
if (typeof Dashboard !== 'undefined') {
|
||||
Dashboard.updateCharts(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize peer details view
|
||||
*/
|
||||
function initPeerDetails() {
|
||||
if (currentData) {
|
||||
const processed = dataProcessor.processSystemState(currentData);
|
||||
updatePeerDetailsList(processed.peers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render peer with profile (avatar + display name or peer ID)
|
||||
*/
|
||||
function renderPeerWithProfile(peer, avatarSize = 32) {
|
||||
const profile = peer.profile || null;
|
||||
const displayName = profile?.displayName || null;
|
||||
const avatarHash = profile?.avatarHash || null;
|
||||
const avatarUrl = avatarHash
|
||||
? `https://global.profile/api/profile/avatar/${peer.id}/${avatarSize}`
|
||||
: null;
|
||||
const peerIdShort = formatPeerId(peer.id, true);
|
||||
|
||||
const avatarHtml = avatarUrl
|
||||
? `<img src="${avatarUrl}" alt="${displayName || peerIdShort}" class="avatar-image" style="width: ${avatarSize}px; height: ${avatarSize}px; margin-right: 0.5rem; vertical-align: middle;" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';" onclick="event.stopPropagation(); if(window.ProfileModal){window.ProfileModal.open('${peer.id}');}">
|
||||
<div class="avatar-placeholder-circle" style="width: ${avatarSize}px; height: ${avatarSize}px; margin-right: 0.5rem; vertical-align: middle; display: none;" onclick="event.stopPropagation(); if(window.ProfileModal){window.ProfileModal.open('${peer.id}');}">
|
||||
${(displayName || peerIdShort).charAt(0).toUpperCase()}
|
||||
</div>`
|
||||
: `<div class="avatar-placeholder-circle" style="width: ${avatarSize}px; height: ${avatarSize}px; margin-right: 0.5rem; vertical-align: middle;" onclick="event.stopPropagation(); if(window.ProfileModal){window.ProfileModal.open('${peer.id}');}">
|
||||
${(displayName || peerIdShort).charAt(0).toUpperCase()}
|
||||
</div>`;
|
||||
|
||||
const nameHtml = displayName
|
||||
? `<span class="peer-name-primary">${escapeHtml(displayName)}</span><span class="peer-name-secondary" style="margin-left: 0.5rem;">${peerIdShort}</span>`
|
||||
: `<span class="peer-name-secondary" style="font-size: 0.875rem;">${peerIdShort}</span>`;
|
||||
|
||||
return `
|
||||
<div class="peer-name-display">
|
||||
${avatarHtml}
|
||||
<span>${nameHtml}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peer details list
|
||||
*/
|
||||
function updatePeerDetailsList(peers) {
|
||||
const peerList = document.getElementById('peerList');
|
||||
if (!peerList) return;
|
||||
|
||||
peerList.innerHTML = peers.map(peer => `
|
||||
<div class="peer-item ${peer.connected ? 'connected' : 'disconnected'}"
|
||||
onclick="showPeerDetails('${peer.id}')">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="flex: 1;">
|
||||
${renderPeerWithProfile(peer, 32)}
|
||||
<div class="peer-status-text">
|
||||
${peer.connected ? 'Connected' : 'Disconnected'}
|
||||
${peer.isLocal ? '• Local' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="peer-uptime-text">
|
||||
${formatDuration(peer.uptime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show peer details in side panel
|
||||
*/
|
||||
function showPeerDetails(peerId) {
|
||||
if (!currentData) return;
|
||||
|
||||
const details = dataProcessor.getPeerDetails(peerId, currentData);
|
||||
if (!details) return;
|
||||
|
||||
// Find the peer object to get profile data
|
||||
const peer = currentData.peers?.find(p => p.id === peerId);
|
||||
const profile = peer?.profile || null;
|
||||
|
||||
const panel = document.getElementById('peerDetailsPanel');
|
||||
const content = document.getElementById('peerDetailsContent');
|
||||
|
||||
if (panel && content) {
|
||||
panel.classList.remove('hidden');
|
||||
panel.style.display = 'block';
|
||||
|
||||
const profileSection = profile ? `
|
||||
<div class="details-section">
|
||||
${renderPeerWithProfile({ id: peerId, profile }, 64)}
|
||||
${profile.bio ? `<p class="details-text" style="margin-top: 0.5rem;">${escapeHtml(profile.bio)}</p>` : ''}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="details-content">
|
||||
${profileSection}
|
||||
<div class="details-item">
|
||||
<h4>Peer ID</h4>
|
||||
<p class="details-item">${details.id}</p>
|
||||
</div>
|
||||
<div class="details-item">
|
||||
<h4>Status</h4>
|
||||
<p class="details-text">${details.connected ? 'Connected' : 'Disconnected'}</p>
|
||||
</div>
|
||||
<div class="details-item">
|
||||
<h4>Uptime</h4>
|
||||
<p class="details-text">${details.formattedUptime}</p>
|
||||
</div>
|
||||
<div class="details-item">
|
||||
<h4>Metrics</h4>
|
||||
<div class="details-metrics">
|
||||
<p>Connections: ${details.metrics.connections}</p>
|
||||
<p>Avg Duration: ${formatDuration(details.metrics.avgDuration)}</p>
|
||||
<p>Last Seen: ${details.formattedLastSeen}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Data processor for transforming API data to visualization format
|
||||
*/
|
||||
|
||||
class DataProcessor {
|
||||
constructor() {
|
||||
this.cache = {
|
||||
system: null,
|
||||
topology: null,
|
||||
metrics: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process system state data
|
||||
*/
|
||||
processSystemState(data) {
|
||||
this.cache.system = data;
|
||||
const connections = this.processConnections(data);
|
||||
return {
|
||||
peers: this.processPeers(data.peers || []),
|
||||
domains: this.processDomains(data.domains || []),
|
||||
connections: connections,
|
||||
localPeerId: data.localPeerId,
|
||||
metrics: data.metrics || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process peers data
|
||||
*/
|
||||
processPeers(peers) {
|
||||
return peers.map(peer => ({
|
||||
id: peer.id,
|
||||
label: formatPeerId(peer.id, true),
|
||||
type: 'peer',
|
||||
connected: peer.connected || false,
|
||||
isLocal: peer.isLocal || false,
|
||||
connectTime: peer.connectTime,
|
||||
uptime: peer.uptime || 0,
|
||||
metrics: peer.metrics || {},
|
||||
history: peer.history || [],
|
||||
profile: peer.profile || null
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process domains data
|
||||
*/
|
||||
processDomains(domains) {
|
||||
return domains.map(domain => ({
|
||||
id: domain.domain,
|
||||
label: domain.domain,
|
||||
type: 'domain',
|
||||
hash: domain.hash || 'none',
|
||||
isLocal: domain.consensus?.resolvedClaimant === this.cache.system?.localPeerId,
|
||||
owner: domain.consensus?.resolvedClaimant || null,
|
||||
consensus: domain.consensus || {}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process connections
|
||||
*/
|
||||
processConnections(data) {
|
||||
const connections = [];
|
||||
const localPeerId = data.localPeerId;
|
||||
|
||||
// Peer-to-peer connections from peerChannels
|
||||
if (data.peerChannels && Array.isArray(data.peerChannels)) {
|
||||
for (const channelData of data.peerChannels) {
|
||||
const peerId = channelData.peerId;
|
||||
if (channelData.channels && Array.isArray(channelData.channels)) {
|
||||
for (const channel of channelData.channels) {
|
||||
if (channel.peerId && channel.peerId !== peerId) {
|
||||
connections.push({
|
||||
source: peerId,
|
||||
target: channel.peerId,
|
||||
type: 'channel',
|
||||
protocol: channel.protocol || 'unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if peerChannels is a Map (from state)
|
||||
// This handles the case where data comes directly from state
|
||||
if (data.peerChannels && !Array.isArray(data.peerChannels)) {
|
||||
// It might be a Map-like structure, try to iterate
|
||||
try {
|
||||
for (const [peerId, channels] of Object.entries(data.peerChannels)) {
|
||||
if (Array.isArray(channels)) {
|
||||
for (const channel of channels) {
|
||||
if (channel && channel.peerId && channel.peerId !== peerId) {
|
||||
connections.push({
|
||||
source: peerId,
|
||||
target: channel.peerId,
|
||||
type: 'channel',
|
||||
protocol: channel.protocol || 'unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore if we can't iterate
|
||||
}
|
||||
}
|
||||
|
||||
// Domain ownership connections
|
||||
if (data.domains && Array.isArray(data.domains)) {
|
||||
for (const domain of data.domains) {
|
||||
if (domain.consensus && domain.consensus.resolvedClaimant) {
|
||||
connections.push({
|
||||
source: domain.consensus.resolvedClaimant,
|
||||
target: domain.domain,
|
||||
type: 'ownership'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process topology data
|
||||
*/
|
||||
processTopology(data) {
|
||||
this.cache.topology = data;
|
||||
// Return topology data as-is, conversion to vis-network format happens in convertToVisNetworkFormat
|
||||
return {
|
||||
nodes: data.nodes || [],
|
||||
edges: data.edges || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate node size based on connections
|
||||
*/
|
||||
calculateNodeSize(node) {
|
||||
if (node.type === 'peer') {
|
||||
return node.connected ? 10 : 8;
|
||||
} else if (node.type === 'domain') {
|
||||
return 12;
|
||||
}
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter nodes and edges based on criteria
|
||||
*/
|
||||
filterGraph(nodes, edges, filters) {
|
||||
let filteredNodes = [...nodes];
|
||||
let filteredEdges = [...edges];
|
||||
|
||||
// Filter by connected peers
|
||||
if (filters.showConnected !== undefined && !filters.showConnected) {
|
||||
filteredNodes = filteredNodes.filter(node =>
|
||||
node.type !== 'peer' || !node.connected
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by domains
|
||||
if (filters.showDomains !== undefined && !filters.showDomains) {
|
||||
filteredNodes = filteredNodes.filter(node => node.type !== 'domain');
|
||||
}
|
||||
|
||||
// Mark nodes that match search term (don't filter them out)
|
||||
if (filters.searchTerm) {
|
||||
const searchLower = filters.searchTerm.toLowerCase();
|
||||
filteredNodes = filteredNodes.map(node => ({
|
||||
...node,
|
||||
matchesSearch: node.id.toLowerCase().includes(searchLower) ||
|
||||
node.label.toLowerCase().includes(searchLower)
|
||||
}));
|
||||
} else {
|
||||
// Clear search match flag if no search term
|
||||
filteredNodes = filteredNodes.map(node => ({
|
||||
...node,
|
||||
matchesSearch: false
|
||||
}));
|
||||
}
|
||||
|
||||
// Filter edges to only include connections between visible nodes
|
||||
const visibleNodeIds = new Set(filteredNodes.map(n => n.id));
|
||||
filteredEdges = filteredEdges.filter(edge => {
|
||||
const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
|
||||
const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
|
||||
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
|
||||
});
|
||||
|
||||
return { nodes: filteredNodes, edges: filteredEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate metrics
|
||||
*/
|
||||
aggregateMetrics(data) {
|
||||
if (!data) {
|
||||
return {
|
||||
totalPeers: 0,
|
||||
connectedPeers: 0,
|
||||
totalDomains: 0,
|
||||
activeConnections: 0,
|
||||
avgConnectionDuration: 0,
|
||||
networkHealth: 0
|
||||
};
|
||||
}
|
||||
|
||||
const peers = data.peers || [];
|
||||
const connectedPeers = peers.filter(p => p && p.connected);
|
||||
const domains = data.domains || [];
|
||||
const connections = data.connections || [];
|
||||
|
||||
// Calculate average connection duration
|
||||
let totalDuration = 0;
|
||||
let connectionCount = 0;
|
||||
peers.forEach(peer => {
|
||||
if (peer && peer.metrics) {
|
||||
if (peer.metrics.totalDuration) {
|
||||
totalDuration += peer.metrics.totalDuration;
|
||||
}
|
||||
if (peer.metrics.connections) {
|
||||
connectionCount += peer.metrics.connections;
|
||||
} else if (peer.connected) {
|
||||
connectionCount += 1;
|
||||
}
|
||||
} else if (peer && peer.connected) {
|
||||
connectionCount += 1;
|
||||
}
|
||||
});
|
||||
const avgDuration = connectionCount > 0 ? totalDuration / connectionCount : 0;
|
||||
|
||||
return {
|
||||
totalPeers: peers.length,
|
||||
connectedPeers: connectedPeers.length,
|
||||
totalDomains: domains.length,
|
||||
activeConnections: connections.length,
|
||||
avgConnectionDuration: avgDuration,
|
||||
networkHealth: this.calculateNetworkHealth(peers, domains)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate network health score (0-100)
|
||||
*/
|
||||
calculateNetworkHealth(peers, domains) {
|
||||
if (peers.length === 0) return 0;
|
||||
|
||||
const connectedRatio = peers.filter(p => p.connected).length / peers.length;
|
||||
const domainOwnershipRatio = domains.length > 0 ?
|
||||
domains.filter(d => d.isLocal).length / domains.length : 0;
|
||||
|
||||
// Weight: 70% peer connectivity, 30% domain ownership
|
||||
return Math.round(connectedRatio * 70 + domainOwnershipRatio * 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peer details for display
|
||||
*/
|
||||
getPeerDetails(peerId, systemData) {
|
||||
const peer = (systemData.peers || []).find(p => p.id === peerId);
|
||||
if (!peer) return null;
|
||||
|
||||
return {
|
||||
id: peer.id,
|
||||
connected: peer.connected,
|
||||
isLocal: peer.isLocal,
|
||||
connectTime: peer.connectTime,
|
||||
uptime: peer.uptime,
|
||||
formattedUptime: formatDuration(peer.uptime),
|
||||
metrics: {
|
||||
connections: peer.metrics.connections || 0,
|
||||
totalDuration: peer.metrics.totalDuration || 0,
|
||||
avgDuration: peer.metrics.avgDuration || 0,
|
||||
lastSeen: peer.metrics.lastSeen
|
||||
},
|
||||
history: peer.history || [],
|
||||
formattedLastSeen: formatTimestamp(peer.metrics.lastSeen)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert topology data to vis-network format
|
||||
*/
|
||||
convertToVisNetworkFormat(topologyData) {
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
|
||||
// Process nodes
|
||||
for (const node of topologyData.nodes || []) {
|
||||
const visNode = {
|
||||
id: node.id,
|
||||
label: node.label || node.id,
|
||||
title: this.buildNodeTooltip(node)
|
||||
};
|
||||
|
||||
// Preserve original properties for filtering
|
||||
visNode.type = node.type;
|
||||
if (node.type === 'peer') {
|
||||
visNode.connected = node.connected;
|
||||
visNode.isLocal = node.isLocal;
|
||||
visNode.profile = node.profile;
|
||||
|
||||
// Use avatar image if profile available
|
||||
if (node.profile && node.profile.avatarHash) {
|
||||
visNode.shape = 'circularImage';
|
||||
visNode.image = `https://global.profile/api/profile/avatar/${node.id}/64`;
|
||||
visNode.brokenImage = this.getDefaultAvatarUrl(node);
|
||||
} else {
|
||||
visNode.shape = 'dot';
|
||||
}
|
||||
|
||||
// Set color based on connection status
|
||||
if (node.isLocal) {
|
||||
visNode.color = {
|
||||
background: '#6366f1', // var(--primary)
|
||||
border: '#4f46e5', // var(--primary-dark)
|
||||
highlight: { background: '#818cf8', border: '#6366f1' }
|
||||
};
|
||||
} else if (node.connected) {
|
||||
visNode.color = {
|
||||
background: '#10b981', // var(--success)
|
||||
border: '#0ea66e',
|
||||
highlight: { background: '#34d399', border: '#10b981' }
|
||||
};
|
||||
} else {
|
||||
visNode.color = {
|
||||
background: '#64748b', // var(--text-muted)
|
||||
border: '#475569',
|
||||
highlight: { background: '#94a3b8', border: '#64748b' }
|
||||
};
|
||||
}
|
||||
|
||||
// Use display name if available
|
||||
if (node.profile && node.profile.displayName) {
|
||||
visNode.label = node.profile.displayName;
|
||||
}
|
||||
} else if (node.type === 'domain') {
|
||||
visNode.shape = 'diamond';
|
||||
visNode.color = {
|
||||
background: '#8b5cf6', // var(--secondary)
|
||||
border: '#7c3aed',
|
||||
highlight: { background: '#a78bfa', border: '#8b5cf6' }
|
||||
};
|
||||
visNode.owner = node.owner;
|
||||
visNode.isLocal = node.isLocal;
|
||||
}
|
||||
|
||||
// Set size
|
||||
visNode.size = node.size || 20;
|
||||
|
||||
nodes.push(visNode);
|
||||
}
|
||||
|
||||
// Process edges
|
||||
for (const edge of topologyData.edges || []) {
|
||||
const visEdge = {
|
||||
from: edge.source,
|
||||
to: edge.target,
|
||||
arrows: 'to',
|
||||
color: {
|
||||
color: '#334155', // var(--bg-tertiary)
|
||||
highlight: '#6366f1' // var(--primary)
|
||||
},
|
||||
width: 2
|
||||
};
|
||||
|
||||
// Customize edge based on type
|
||||
if (edge.type === 'ownership') {
|
||||
visEdge.dashes = true;
|
||||
visEdge.color.color = '#8b5cf6'; // var(--secondary)
|
||||
visEdge.width = 3;
|
||||
} else if (edge.type === 'channel') {
|
||||
visEdge.width = 2;
|
||||
visEdge.title = `Protocol: ${edge.protocol || 'unknown'}`;
|
||||
}
|
||||
|
||||
edges.push(visEdge);
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tooltip text for a node
|
||||
*/
|
||||
buildNodeTooltip(node) {
|
||||
const parts = [];
|
||||
|
||||
if (node.type === 'peer') {
|
||||
if (node.profile && node.profile.displayName) {
|
||||
parts.push(node.profile.displayName);
|
||||
}
|
||||
parts.push(`Peer ID: ${formatPeerId(node.id, true)}`);
|
||||
if (node.isLocal) {
|
||||
parts.push('Local Node');
|
||||
} else if (node.connected) {
|
||||
parts.push('Connected');
|
||||
} else {
|
||||
parts.push('Disconnected');
|
||||
}
|
||||
if (node.profile && node.profile.bio) {
|
||||
parts.push(`Bio: ${node.profile.bio.substring(0, 50)}...`);
|
||||
}
|
||||
} else if (node.type === 'domain') {
|
||||
parts.push(`Domain: ${node.id}`);
|
||||
if (node.owner) {
|
||||
parts.push(`Owner: ${formatPeerId(node.owner, true)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default avatar URL for a peer
|
||||
*/
|
||||
getDefaultAvatarUrl(node) {
|
||||
// Return a data URI for a simple colored circle
|
||||
const color = node.isLocal ? '#6366f1' : (node.connected ? '#10b981' : '#64748b');
|
||||
const initial = (node.profile?.displayName || node.id || '?').charAt(0).toUpperCase();
|
||||
// Return null to use vis-network's default broken image handling
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
const dataProcessor = new DataProcessor();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Utility functions for peer.visualize
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format peer ID (short or long)
|
||||
*/
|
||||
function formatPeerId(peerId, short = true) {
|
||||
if (!peerId) return 'Unknown';
|
||||
if (short) {
|
||||
return peerId.slice(0, 16) + '...';
|
||||
}
|
||||
return peerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable string
|
||||
*/
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
if (diff < 60000) {
|
||||
return 'Just now';
|
||||
} else if (diff < 3600000) {
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
} else if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration (milliseconds to readable string)
|
||||
*/
|
||||
function formatDuration(ms) {
|
||||
if (!ms || ms === 0) return '0s';
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate color for node based on type and state
|
||||
*/
|
||||
function getNodeColor(node) {
|
||||
if (node.type === 'peer') {
|
||||
if (node.isLocal) {
|
||||
return '#3b82f6'; // Blue for local node
|
||||
} else if (node.connected) {
|
||||
return '#10b981'; // Green for connected peers
|
||||
} else {
|
||||
return '#6b7280'; // Gray for disconnected
|
||||
}
|
||||
} else if (node.type === 'domain') {
|
||||
return '#a855f7'; // Purple for domains
|
||||
}
|
||||
return '#9ca3af'; // Default gray
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function
|
||||
*/
|
||||
function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points
|
||||
*/
|
||||
function distance(x1, y1, x2, y2) {
|
||||
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone object
|
||||
*/
|
||||
function deepClone(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two arrays are equal
|
||||
*/
|
||||
function arraysEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((val, idx) => val === b[idx]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
formatPeerId,
|
||||
formatTimestamp,
|
||||
formatDuration,
|
||||
getNodeColor,
|
||||
debounce,
|
||||
throttle,
|
||||
distance,
|
||||
generateId,
|
||||
deepClone,
|
||||
arraysEqual,
|
||||
formatBytes
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Base Visualization Class
|
||||
* Provides common functionality for all visualizations
|
||||
*/
|
||||
|
||||
class BaseVisualization {
|
||||
constructor(container, options = {}) {
|
||||
if (!container) {
|
||||
throw new Error('Container element is required');
|
||||
}
|
||||
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: '',
|
||||
showOfflinePeers: false
|
||||
};
|
||||
this.data = null;
|
||||
this.resizeObserver = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the visualization
|
||||
* Must be implemented by subclasses
|
||||
*/
|
||||
init() {
|
||||
this.setupContainer();
|
||||
this.setupResizeObserver();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup container styles
|
||||
*/
|
||||
setupContainer() {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.right = '0';
|
||||
this.container.style.bottom = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup resize observer
|
||||
*/
|
||||
setupResizeObserver() {
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle container resize
|
||||
* Override in subclasses if needed
|
||||
*/
|
||||
handleResize() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container dimensions
|
||||
* @returns {{width: number, height: number}}
|
||||
*/
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
const width = Math.max(rect.width || this.container.clientWidth || 800, 400);
|
||||
const height = Math.max(rect.height || this.container.clientHeight || 600, 400);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update with full system state
|
||||
* Override in subclasses
|
||||
* @param {Object} data - System state data
|
||||
*/
|
||||
updateData(data) {
|
||||
this.data = data;
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Update with topology-only data
|
||||
* Override in subclasses
|
||||
* @param {Object} data - Topology data
|
||||
*/
|
||||
updateTopology(data) {
|
||||
this.data = data;
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filter settings
|
||||
* @param {Object} filters - Filter object
|
||||
*/
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
// Override in subclasses to apply filters
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visualization type
|
||||
* Override in subclasses
|
||||
* @returns {string}
|
||||
*/
|
||||
getType() {
|
||||
return 'base';
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the visualization and cleanup resources
|
||||
*/
|
||||
destroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
this.initialized = false;
|
||||
this.data = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Circular/Radial layout visualization using D3.js
|
||||
*/
|
||||
|
||||
class CircularGraph {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
this.localNodeId = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'circular';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
this.setupContainer();
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
this.radius = Math.min(width, height) * 0.35;
|
||||
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
this.g = this.svg.append('g')
|
||||
.attr('transform', `translate(${centerX},${centerY})`);
|
||||
|
||||
// Add zoom
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 3])
|
||||
.on('zoom', (event) => {
|
||||
this.g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(zoom);
|
||||
|
||||
// Setup resize observer
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.right = '0';
|
||||
this.container.style.bottom = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(rect.width || 800, 400),
|
||||
height: Math.max(rect.height || 600, 400)
|
||||
};
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (!this.svg) return;
|
||||
const { width, height } = this.getDimensions();
|
||||
this.radius = Math.min(width, height) * 0.35;
|
||||
|
||||
this.svg.attr('width', width).attr('height', height);
|
||||
|
||||
// Reset transform
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
this.g.attr('transform', `translate(${centerX},${centerY})`);
|
||||
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
updateData(data) {
|
||||
if (!data) return;
|
||||
|
||||
const allNodes = [...(data.peers || []), ...(data.domains || [])];
|
||||
const allLinks = data.connections || [];
|
||||
const filtered = dataProcessor.filterGraph(allNodes, allLinks, this.filters);
|
||||
|
||||
this.localNodeId = data.localPeerId;
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateTopology(data) {
|
||||
if (!data) return;
|
||||
|
||||
const filtered = dataProcessor.filterGraph(data.nodes || [], data.edges || [], this.filters);
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateVisualization() {
|
||||
if (!this.svg || !this.nodes || this.nodes.length === 0) return;
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Position nodes in a circle
|
||||
const angleStep = (2 * Math.PI) / this.nodes.length;
|
||||
this.nodes.forEach((node, i) => {
|
||||
if (node.isLocal && node.type === 'peer') {
|
||||
// Local node at center
|
||||
node.x = 0;
|
||||
node.y = 0;
|
||||
} else {
|
||||
// Other nodes in circle
|
||||
const angle = i * angleStep;
|
||||
node.x = this.radius * Math.cos(angle);
|
||||
node.y = this.radius * Math.sin(angle);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear previous
|
||||
this.g.selectAll('*').remove();
|
||||
|
||||
// Draw links
|
||||
const link = this.g.selectAll('.link')
|
||||
.data(this.links)
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('class', 'link')
|
||||
.attr('d', d => {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
if (!source || !target) return 'M 0 0';
|
||||
|
||||
const sourceX = source.x || 0;
|
||||
const sourceY = source.y || 0;
|
||||
const targetX = target.x || 0;
|
||||
const targetY = target.y || 0;
|
||||
|
||||
// Curved path for circular layout
|
||||
const midX = (sourceX + targetX) / 2;
|
||||
const midY = (sourceY + targetY) / 2;
|
||||
const dx = targetX - sourceX;
|
||||
const dy = targetY - sourceY;
|
||||
const perpX = -dy;
|
||||
const perpY = dx;
|
||||
const curveOffset = Math.min(Math.sqrt(dx * dx + dy * dy) * 0.3, 50);
|
||||
const curveX = midX + (perpX / Math.sqrt(perpX * perpX + perpY * perpY)) * curveOffset;
|
||||
const curveY = midY + (perpY / Math.sqrt(perpX * perpX + perpY * perpY)) * curveOffset;
|
||||
|
||||
return `M ${sourceX} ${sourceY} Q ${curveX} ${curveY} ${targetX} ${targetY}`;
|
||||
})
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', d => d.type === 'ownership' ? '#a855f7' : '#6b7280')
|
||||
.attr('stroke-width', d => d.type === 'ownership' ? 2.5 : 2)
|
||||
.attr('opacity', 0.6);
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroups = this.g.selectAll('.node')
|
||||
.data(this.nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`)
|
||||
.style('cursor', d => d.type === 'peer' ? 'pointer' : 'default');
|
||||
|
||||
// Add circles or avatars
|
||||
nodeGroups.each(function(d) {
|
||||
const nodeGroup = d3.select(this);
|
||||
|
||||
if (d.type === 'peer' && d.profile && d.profile.avatarHash) {
|
||||
const avatarUrl = `https://global.profile/api/profile/avatar/${d.id}/24`;
|
||||
const avatarSize = 24;
|
||||
nodeGroup.append('image')
|
||||
.attr('href', avatarUrl)
|
||||
.attr('x', -avatarSize / 2)
|
||||
.attr('y', -avatarSize / 2)
|
||||
.attr('width', avatarSize)
|
||||
.attr('height', avatarSize)
|
||||
.attr('clip-path', 'url(#circular-avatar-clip)')
|
||||
.on('error', function() {
|
||||
d3.select(this).remove();
|
||||
addCircle(nodeGroup, d);
|
||||
});
|
||||
|
||||
// Add clip path if needed
|
||||
const svg = this.ownerSVGElement;
|
||||
if (svg && !svg.querySelector('#circular-avatar-clip')) {
|
||||
let defs = svg.querySelector('defs');
|
||||
if (!defs) {
|
||||
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
svg.insertBefore(defs, svg.firstChild);
|
||||
}
|
||||
const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
|
||||
clipPath.setAttribute('id', 'circular-avatar-clip');
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', 0);
|
||||
circle.setAttribute('cy', 0);
|
||||
circle.setAttribute('r', avatarSize / 2);
|
||||
clipPath.appendChild(circle);
|
||||
defs.appendChild(clipPath);
|
||||
}
|
||||
} else {
|
||||
addCircle(nodeGroup, d);
|
||||
}
|
||||
});
|
||||
|
||||
function addCircle(nodeGroup, d) {
|
||||
const radius = d.isLocal ? 14 : (d.type === 'peer' ? 12 : 10);
|
||||
nodeGroup.append('circle')
|
||||
.attr('r', radius)
|
||||
.attr('fill', getNodeColor(d))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', d.isLocal ? 3 : 2);
|
||||
}
|
||||
|
||||
// Add labels
|
||||
nodeGroups.append('text')
|
||||
.attr('dy', d => {
|
||||
const radius = d.isLocal ? 14 : (d.type === 'peer' ? 12 : 10);
|
||||
return radius + 12;
|
||||
})
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.text(d => {
|
||||
const label = d.label || d.id;
|
||||
return label.length > 12 ? label.substring(0, 12) + '...' : label;
|
||||
})
|
||||
.style('text-shadow', '1px 1px 2px rgba(0,0,0,0.8)')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
// Add click handler
|
||||
nodeGroups.on('click', (event, d) => {
|
||||
if (d.type === 'peer') {
|
||||
event.stopPropagation();
|
||||
if (window.ProfileModal) {
|
||||
window.ProfileModal.open(d.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register with visualization registry
|
||||
if (typeof window !== 'undefined' && window.VisualizationRegistry) {
|
||||
window.VisualizationRegistry.register('circular', CircularGraph, ['network-graph', 'domain-map']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* System overview dashboard with charts
|
||||
*/
|
||||
|
||||
const Dashboard = {
|
||||
timelineChart: null,
|
||||
durationChart: null,
|
||||
|
||||
init() {
|
||||
this.initTimelineChart();
|
||||
this.initDurationChart();
|
||||
},
|
||||
|
||||
initTimelineChart() {
|
||||
const canvas = document.getElementById('timelineChart');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas size
|
||||
const container = canvas.parentElement;
|
||||
if (container) {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight || 300;
|
||||
}
|
||||
|
||||
this.timelineChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Connected Peers',
|
||||
data: [],
|
||||
borderColor: '#10b981', // var(--success)
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
right: 10
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#cbd5e1', // var(--text-secondary)
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8', // var(--text-tertiary)
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
},
|
||||
grid: {
|
||||
color: '#334155', // var(--bg-tertiary)
|
||||
drawBorder: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#94a3b8', // var(--text-tertiary)
|
||||
precision: 0
|
||||
},
|
||||
grid: {
|
||||
color: '#334155', // var(--bg-tertiary)
|
||||
drawBorder: false
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
initDurationChart() {
|
||||
const canvas = document.getElementById('durationChart');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas size
|
||||
const container = canvas.parentElement;
|
||||
if (container) {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight || 300;
|
||||
}
|
||||
|
||||
this.durationChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Connection Duration (hours)',
|
||||
data: [],
|
||||
backgroundColor: '#6366f1', // var(--primary)
|
||||
borderColor: '#4f46e5', // var(--primary-dark)
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
right: 10
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#cbd5e1', // var(--text-secondary)
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8', // var(--text-tertiary)
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
},
|
||||
grid: {
|
||||
color: '#334155', // var(--bg-tertiary)
|
||||
drawBorder: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#94a3b8', // var(--text-tertiary)
|
||||
precision: 1
|
||||
},
|
||||
grid: {
|
||||
color: '#334155', // var(--bg-tertiary)
|
||||
drawBorder: false
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateCharts(data) {
|
||||
this.updateTimelineChart(data);
|
||||
this.updateDurationChart(data);
|
||||
},
|
||||
|
||||
updateTimelineChart(data) {
|
||||
if (!this.timelineChart) return;
|
||||
|
||||
const peers = data.peers || [];
|
||||
const connectedCount = peers.filter(p => p && p.connected).length;
|
||||
const now = new Date();
|
||||
|
||||
// Add current data point
|
||||
const labels = this.timelineChart.data.labels;
|
||||
const dataset = this.timelineChart.data.datasets[0];
|
||||
|
||||
// Format time as HH:MM:SS
|
||||
const timeStr = now.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
labels.push(timeStr);
|
||||
dataset.data.push(connectedCount);
|
||||
|
||||
// Keep only last 30 points for better visibility
|
||||
if (labels.length > 30) {
|
||||
labels.shift();
|
||||
dataset.data.shift();
|
||||
}
|
||||
|
||||
this.timelineChart.update('none');
|
||||
},
|
||||
|
||||
updateDurationChart(data) {
|
||||
if (!this.durationChart) return;
|
||||
|
||||
const peers = data.peers || [];
|
||||
const durations = peers
|
||||
.filter(p => p && p.metrics && p.metrics.avgDuration && p.metrics.avgDuration > 0)
|
||||
.map(p => ({
|
||||
label: formatPeerId(p.id, true),
|
||||
duration: p.metrics.avgDuration / (1000 * 60 * 60) // Convert to hours
|
||||
}))
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
.slice(0, 10); // Top 10
|
||||
|
||||
// If no data, show empty chart
|
||||
if (durations.length === 0) {
|
||||
this.durationChart.data.labels = [];
|
||||
this.durationChart.data.datasets[0].data = [];
|
||||
} else {
|
||||
this.durationChart.data.labels = durations.map(d => d.label);
|
||||
this.durationChart.data.datasets[0].data = durations.map(d => Math.round(d.duration * 10) / 10); // Round to 1 decimal
|
||||
}
|
||||
|
||||
this.durationChart.update();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => Dashboard.init());
|
||||
} else {
|
||||
Dashboard.init();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Domain Map Visualization using vis-network
|
||||
*/
|
||||
|
||||
class DomainMap {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.network = null;
|
||||
this.nodes = null;
|
||||
this.edges = null;
|
||||
this.currentLayout = 'force';
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the domain map
|
||||
*/
|
||||
init() {
|
||||
if (!this.container) {
|
||||
console.error('DomainMap: Container element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure container uses full height
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
this.container.style.minHeight = '0';
|
||||
|
||||
// Create data structure
|
||||
this.nodes = new vis.DataSet([]);
|
||||
this.edges = new vis.DataSet([]);
|
||||
|
||||
// Configure options
|
||||
const options = this.getDefaultOptions();
|
||||
|
||||
// Create network
|
||||
const data = {
|
||||
nodes: this.nodes,
|
||||
edges: this.edges
|
||||
};
|
||||
|
||||
this.network = new vis.Network(this.container, data, options);
|
||||
|
||||
// Setup event handlers
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Handle window resize
|
||||
this.resizeHandler = () => {
|
||||
if (this.network) {
|
||||
this.network.fit();
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default vis-network options
|
||||
*/
|
||||
getDefaultOptions() {
|
||||
return {
|
||||
nodes: {
|
||||
borderWidth: 2,
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
size: 5,
|
||||
x: 2,
|
||||
y: 2
|
||||
},
|
||||
font: {
|
||||
color: '#f1f5f9', // var(--text-primary)
|
||||
size: 14,
|
||||
face: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", Roboto, sans-serif'
|
||||
},
|
||||
scaling: {
|
||||
min: 10,
|
||||
max: 50
|
||||
}
|
||||
},
|
||||
edges: {
|
||||
width: 3,
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0,0,0,0.2)',
|
||||
size: 3
|
||||
},
|
||||
smooth: {
|
||||
type: 'continuous',
|
||||
roundness: 0.5
|
||||
},
|
||||
dashes: true
|
||||
},
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 200,
|
||||
fit: true
|
||||
},
|
||||
barnesHut: {
|
||||
gravitationalConstant: -2000,
|
||||
centralGravity: 0.1,
|
||||
springLength: 200,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0.5
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
dragNodes: true,
|
||||
dragView: true,
|
||||
zoomView: true,
|
||||
hover: true,
|
||||
tooltipDelay: 200,
|
||||
selectConnectedEdges: true
|
||||
},
|
||||
layout: {
|
||||
improvedLayout: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers
|
||||
*/
|
||||
setupEventHandlers() {
|
||||
if (!this.network) return;
|
||||
|
||||
// Handle node clicks
|
||||
this.network.on('click', (params) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const nodeId = params.nodes[0];
|
||||
const node = this.nodes.get(nodeId);
|
||||
|
||||
if (node && node.type === 'peer' && window.ProfileModal) {
|
||||
// Open ProfileModal for peer nodes
|
||||
window.ProfileModal.open(nodeId);
|
||||
} else if (node && node.type === 'domain') {
|
||||
// Show domain details in side panel
|
||||
this.showDomainDetails(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle double click - fit to screen
|
||||
this.network.on('doubleClick', (params) => {
|
||||
if (params.nodes.length === 0) {
|
||||
this.network.fit();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle hover for highlighting
|
||||
this.network.on('hoverNode', (params) => {
|
||||
this.container.style.cursor = 'pointer';
|
||||
});
|
||||
|
||||
this.network.on('blurNode', () => {
|
||||
this.container.style.cursor = 'default';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show domain details in side panel
|
||||
*/
|
||||
showDomainDetails(domainNode) {
|
||||
// This will be handled by app.js's showDomainDetails function
|
||||
if (window.showDomainDetails) {
|
||||
window.showDomainDetails(domainNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update data from topology
|
||||
*/
|
||||
updateData(topologyData) {
|
||||
if (!topologyData) return;
|
||||
|
||||
// Convert to vis-network format with domain focus
|
||||
const visData = this.convertToDomainMapFormat(topologyData);
|
||||
|
||||
// Apply filters
|
||||
const filtered = this.applyFilters(visData);
|
||||
|
||||
// Update nodes and edges
|
||||
this.nodes.clear();
|
||||
this.nodes.add(filtered.nodes);
|
||||
|
||||
this.edges.clear();
|
||||
this.edges.add(filtered.edges);
|
||||
|
||||
// Apply layout
|
||||
this.applyLayout(this.currentLayout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert topology to domain map format (domains + their owners)
|
||||
*/
|
||||
convertToDomainMapFormat(topologyData) {
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
const peerNodes = new Set();
|
||||
const domainNodes = new Set();
|
||||
|
||||
// First, collect all domain nodes and their owners from edges
|
||||
for (const edge of topologyData.edges || []) {
|
||||
if (edge.type === 'ownership') {
|
||||
const sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
|
||||
const targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
|
||||
|
||||
// Source is the owner (peer), target is the domain
|
||||
peerNodes.add(sourceId);
|
||||
domainNodes.add(targetId);
|
||||
|
||||
// Add ownership edge
|
||||
edges.push({
|
||||
from: sourceId,
|
||||
to: targetId,
|
||||
arrows: 'to',
|
||||
color: {
|
||||
color: '#8b5cf6', // var(--secondary)
|
||||
highlight: '#a78bfa'
|
||||
},
|
||||
width: 3,
|
||||
dashes: true,
|
||||
title: 'Owned by'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process all nodes
|
||||
for (const node of topologyData.nodes || []) {
|
||||
if (node.type === 'domain') {
|
||||
// Add all domain nodes
|
||||
domainNodes.add(node.id);
|
||||
const visNode = {
|
||||
id: node.id,
|
||||
label: node.id,
|
||||
title: this.buildDomainTooltip(node),
|
||||
type: 'domain',
|
||||
shape: 'diamond',
|
||||
color: {
|
||||
background: '#8b5cf6', // var(--secondary)
|
||||
border: '#7c3aed',
|
||||
highlight: { background: '#a78bfa', border: '#8b5cf6' }
|
||||
},
|
||||
size: 25
|
||||
};
|
||||
|
||||
// Check if domain is local
|
||||
if (node.isLocal) {
|
||||
visNode.color.background = '#ec4899'; // var(--accent)
|
||||
visNode.color.border = '#db2777';
|
||||
}
|
||||
|
||||
nodes.push(visNode);
|
||||
} else if (node.type === 'peer') {
|
||||
// Add peer nodes that own domains or are connected
|
||||
if (peerNodes.has(node.id) || node.connected) {
|
||||
const visNode = {
|
||||
id: node.id,
|
||||
label: node.label || node.id,
|
||||
title: this.buildPeerTooltip(node),
|
||||
type: 'peer'
|
||||
};
|
||||
|
||||
// Use avatar if profile available
|
||||
if (node.profile && node.profile.avatarHash) {
|
||||
visNode.shape = 'circularImage';
|
||||
visNode.image = `https://global.profile/api/profile/avatar/${node.id}/64`;
|
||||
visNode.brokenImage = null;
|
||||
} else {
|
||||
visNode.shape = 'dot';
|
||||
}
|
||||
|
||||
// Set color based on connection status
|
||||
if (node.isLocal) {
|
||||
visNode.color = {
|
||||
background: '#6366f1', // var(--primary)
|
||||
border: '#4f46e5',
|
||||
highlight: { background: '#818cf8', border: '#6366f1' }
|
||||
};
|
||||
} else if (node.connected) {
|
||||
visNode.color = {
|
||||
background: '#10b981', // var(--success)
|
||||
border: '#0ea66e',
|
||||
highlight: { background: '#34d399', border: '#10b981' }
|
||||
};
|
||||
} else {
|
||||
visNode.color = {
|
||||
background: '#64748b', // var(--text-muted)
|
||||
border: '#475569',
|
||||
highlight: { background: '#94a3b8', border: '#64748b' }
|
||||
};
|
||||
}
|
||||
|
||||
// Use display name if available
|
||||
if (node.profile && node.profile.displayName) {
|
||||
visNode.label = node.profile.displayName;
|
||||
}
|
||||
|
||||
visNode.size = 20;
|
||||
nodes.push(visNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tooltip text for a domain
|
||||
*/
|
||||
buildDomainTooltip(domainNode) {
|
||||
const parts = [`Domain: ${domainNode.id}`];
|
||||
// Owner information will be shown via edges
|
||||
if (domainNode.isLocal) {
|
||||
parts.push('Local Domain');
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tooltip text for a peer
|
||||
*/
|
||||
buildPeerTooltip(peerNode) {
|
||||
const parts = [];
|
||||
if (peerNode.profile && peerNode.profile.displayName) {
|
||||
parts.push(peerNode.profile.displayName);
|
||||
}
|
||||
parts.push(`Peer ID: ${formatPeerId(peerNode.id, true)}`);
|
||||
if (peerNode.isLocal) {
|
||||
parts.push('Local Node');
|
||||
} else if (peerNode.connected) {
|
||||
parts.push('Connected');
|
||||
} else {
|
||||
parts.push('Disconnected');
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to data
|
||||
*/
|
||||
applyFilters(visData) {
|
||||
let filteredNodes = [...visData.nodes];
|
||||
let filteredEdges = [...visData.edges];
|
||||
|
||||
// Filter by connection status (for peer nodes)
|
||||
if (!this.filters.showConnected) {
|
||||
filteredNodes = filteredNodes.filter(node => {
|
||||
if (node.type !== 'peer') return true;
|
||||
return !node.connected;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by domains
|
||||
if (!this.filters.showDomains) {
|
||||
filteredNodes = filteredNodes.filter(node => node.type !== 'domain');
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (this.filters.searchTerm) {
|
||||
const searchLower = this.filters.searchTerm.toLowerCase();
|
||||
filteredNodes = filteredNodes.filter(node => {
|
||||
const label = (node.label || '').toLowerCase();
|
||||
const id = (node.id || '').toLowerCase();
|
||||
return label.includes(searchLower) || id.includes(searchLower);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter edges to only include connections between visible nodes
|
||||
const visibleNodeIds = new Set(filteredNodes.map(n => n.id));
|
||||
filteredEdges = filteredEdges.filter(edge => {
|
||||
return visibleNodeIds.has(edge.from) && visibleNodeIds.has(edge.to);
|
||||
});
|
||||
|
||||
return { nodes: filteredNodes, edges: filteredEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply layout
|
||||
*/
|
||||
applyLayout(layout) {
|
||||
this.currentLayout = layout;
|
||||
|
||||
if (!this.network) return;
|
||||
|
||||
const options = this.getDefaultOptions();
|
||||
|
||||
switch (layout) {
|
||||
case 'hierarchical':
|
||||
// Hierarchical layout: domains grouped under their owners
|
||||
options.layout = {
|
||||
hierarchical: {
|
||||
direction: 'UD',
|
||||
sortMethod: 'directed',
|
||||
levelSeparation: 200,
|
||||
nodeSpacing: 150,
|
||||
treeSpacing: 300,
|
||||
blockShifting: true,
|
||||
edgeMinimization: true,
|
||||
parentCentralization: true
|
||||
}
|
||||
};
|
||||
options.physics = {
|
||||
enabled: false
|
||||
};
|
||||
break;
|
||||
|
||||
case 'force':
|
||||
default:
|
||||
options.physics = {
|
||||
enabled: true,
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 200,
|
||||
fit: true
|
||||
},
|
||||
barnesHut: {
|
||||
gravitationalConstant: -2000,
|
||||
centralGravity: 0.1,
|
||||
springLength: 200,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0.5
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
this.network.setOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set layout
|
||||
*/
|
||||
setLayout(layout) {
|
||||
this.applyLayout(layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filters and re-apply
|
||||
*/
|
||||
setFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
|
||||
// Re-apply filters to current data
|
||||
if (this.nodes.length > 0 || this.edges.length > 0) {
|
||||
const currentData = {
|
||||
nodes: this.nodes.get(),
|
||||
edges: this.edges.get()
|
||||
};
|
||||
const filtered = this.applyFilters(currentData);
|
||||
|
||||
this.nodes.clear();
|
||||
this.nodes.add(filtered.nodes);
|
||||
this.edges.clear();
|
||||
this.edges.add(filtered.edges);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update topology
|
||||
*/
|
||||
updateTopology(topologyData) {
|
||||
this.updateData(topologyData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the network
|
||||
*/
|
||||
destroy() {
|
||||
if (this.resizeHandler) {
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
this.resizeHandler = null;
|
||||
}
|
||||
if (this.network) {
|
||||
this.network.destroy();
|
||||
this.network = null;
|
||||
}
|
||||
if (this.nodes) {
|
||||
this.nodes.clear();
|
||||
this.nodes = null;
|
||||
}
|
||||
if (this.edges) {
|
||||
this.edges.clear();
|
||||
this.edges = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Force-directed graph visualization using vis-network
|
||||
* Migration example from D3.js
|
||||
*
|
||||
* To use this:
|
||||
* 1. Add to index.html: <script src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
|
||||
* 2. Replace force-graph.js with this implementation
|
||||
*/
|
||||
|
||||
class ForceGraph {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.network = null;
|
||||
this.nodes = [];
|
||||
this.edges = [];
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
|
||||
// vis-network automatically handles container sizing
|
||||
const options = {
|
||||
nodes: {
|
||||
shape: 'dot',
|
||||
size: 16,
|
||||
font: {
|
||||
size: 11,
|
||||
color: '#e5e7eb'
|
||||
},
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
color: {
|
||||
border: '#fff',
|
||||
background: '#6b7280',
|
||||
highlight: {
|
||||
border: '#fbbf24',
|
||||
background: '#6b7280'
|
||||
}
|
||||
}
|
||||
},
|
||||
edges: {
|
||||
width: 2,
|
||||
color: {
|
||||
color: '#6b7280',
|
||||
highlight: '#fbbf24'
|
||||
},
|
||||
smooth: {
|
||||
type: 'continuous'
|
||||
}
|
||||
},
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 100
|
||||
},
|
||||
barnesHut: {
|
||||
gravitationalConstant: -300,
|
||||
centralGravity: 0.1,
|
||||
springLength: 150,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
dragNodes: true,
|
||||
dragView: true,
|
||||
zoomView: true
|
||||
}
|
||||
};
|
||||
|
||||
const data = { nodes: this.nodes, edges: this.edges };
|
||||
this.network = new vis.Network(this.container, data, options);
|
||||
|
||||
// Handle node clicks
|
||||
this.network.on('click', (params) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const nodeId = params.nodes[0];
|
||||
const node = this.nodes.find(n => n.id === nodeId);
|
||||
if (node && node.type === 'peer' && window.ProfileModal) {
|
||||
window.ProfileModal.open(nodeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateData(data) {
|
||||
if (!data) return;
|
||||
const allNodes = [...(data.peers || []), ...(data.domains || [])];
|
||||
const allLinks = data.connections || [];
|
||||
const filtered = dataProcessor.filterGraph(allNodes, allLinks, this.filters);
|
||||
const validNodeIds = new Set(filtered.nodes.map(n => n.id));
|
||||
|
||||
// Preserve positions from existing nodes
|
||||
const existingNodesMap = new Map();
|
||||
if (this.network) {
|
||||
const positions = this.network.getPositions();
|
||||
this.nodes.forEach(n => {
|
||||
if (positions[n.id]) {
|
||||
existingNodesMap.set(n.id, {
|
||||
x: positions[n.id].x,
|
||||
y: positions[n.id].y
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to vis-network format
|
||||
const newNodes = filtered.nodes.map(newNode => {
|
||||
const existing = existingNodesMap.get(newNode.id);
|
||||
const visNode = {
|
||||
id: newNode.id,
|
||||
label: (newNode.label || newNode.id).substring(0, 15) + ((newNode.label || newNode.id).length > 15 ? '...' : ''),
|
||||
type: newNode.type,
|
||||
color: {
|
||||
background: newNode.color || getNodeColor(newNode),
|
||||
border: '#fff'
|
||||
},
|
||||
// Preserve position if it exists
|
||||
x: existing ? existing.x : undefined,
|
||||
y: existing ? existing.y : undefined,
|
||||
fixed: newNode.isLocal && newNode.type === 'peer' // Fix local node
|
||||
};
|
||||
|
||||
// Add custom properties
|
||||
if (newNode.isLocal) {
|
||||
visNode.fixed = true;
|
||||
}
|
||||
|
||||
return visNode;
|
||||
});
|
||||
|
||||
const newEdges = filtered.edges
|
||||
.map(link => {
|
||||
const source = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const target = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
return {
|
||||
from: source,
|
||||
to: target,
|
||||
id: `${source}-${target}`,
|
||||
color: link.type === 'ownership' ? '#a855f7' : '#6b7280'
|
||||
};
|
||||
})
|
||||
.filter(edge => validNodeIds.has(edge.from) && validNodeIds.has(edge.to));
|
||||
|
||||
this.nodes = newNodes;
|
||||
this.edges = newEdges;
|
||||
|
||||
// Update network - vis-network handles smooth updates automatically
|
||||
const networkData = { nodes: this.nodes, edges: this.edges };
|
||||
this.network.setData(networkData);
|
||||
|
||||
// Fix local node position
|
||||
const localNode = this.nodes.find(n => n.fixed);
|
||||
if (localNode && this.network) {
|
||||
const { width, height } = this.getDimensions();
|
||||
this.network.moveNode(localNode.id, width / 2, height / 2);
|
||||
}
|
||||
}
|
||||
|
||||
updateTopology(data) {
|
||||
if (!data) return;
|
||||
const filtered = dataProcessor.filterGraph(data.nodes || [], data.edges || [], this.filters);
|
||||
const validNodeIds = new Set(filtered.nodes.map(n => n.id));
|
||||
|
||||
// Preserve positions
|
||||
const existingNodesMap = new Map();
|
||||
if (this.network) {
|
||||
const positions = this.network.getPositions();
|
||||
this.nodes.forEach(n => {
|
||||
if (positions[n.id]) {
|
||||
existingNodesMap.set(n.id, {
|
||||
x: positions[n.id].x,
|
||||
y: positions[n.id].y
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to vis-network format
|
||||
const newNodes = filtered.nodes.map(newNode => {
|
||||
const existing = existingNodesMap.get(newNode.id);
|
||||
return {
|
||||
id: newNode.id,
|
||||
label: (newNode.label || newNode.id).substring(0, 15) + ((newNode.label || newNode.id).length > 15 ? '...' : ''),
|
||||
type: newNode.type,
|
||||
color: {
|
||||
background: newNode.color || getNodeColor(newNode),
|
||||
border: '#fff'
|
||||
},
|
||||
x: existing ? existing.x : undefined,
|
||||
y: existing ? existing.y : undefined,
|
||||
fixed: newNode.isLocal && newNode.type === 'peer'
|
||||
};
|
||||
});
|
||||
|
||||
const newEdges = filtered.edges
|
||||
.map(link => {
|
||||
const source = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const target = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
return {
|
||||
from: source,
|
||||
to: target,
|
||||
id: `${source}-${target}`,
|
||||
color: link.type === 'ownership' ? '#a855f7' : '#6b7280'
|
||||
};
|
||||
})
|
||||
.filter(edge => validNodeIds.has(edge.from) && validNodeIds.has(edge.to));
|
||||
|
||||
this.nodes = newNodes;
|
||||
this.edges = newEdges;
|
||||
|
||||
const networkData = { nodes: this.nodes, edges: this.edges };
|
||||
this.network.setData(networkData);
|
||||
}
|
||||
|
||||
updateVisualProperties() {
|
||||
if (!this.network) return;
|
||||
|
||||
// Update node colors
|
||||
const updates = this.nodes.map(node => ({
|
||||
id: node.id,
|
||||
color: {
|
||||
background: node.color || getNodeColor(node),
|
||||
border: '#fff'
|
||||
}
|
||||
}));
|
||||
|
||||
this.network.updateNodes(updates);
|
||||
}
|
||||
|
||||
updatePeerProperties(updatedPeers) {
|
||||
if (!this.nodes || !this.network) return;
|
||||
const updatedMap = new Map(updatedPeers.map(p => [p.id, p]));
|
||||
|
||||
const updates = this.nodes
|
||||
.filter(node => node.type === 'peer' && updatedMap.has(node.id))
|
||||
.map(node => {
|
||||
const update = updatedMap.get(node.id);
|
||||
return {
|
||||
id: node.id,
|
||||
color: {
|
||||
background: getNodeColor(update),
|
||||
border: '#fff'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (updates.length > 0) {
|
||||
this.network.updateNodes(updates);
|
||||
}
|
||||
}
|
||||
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(rect.width || 800, 400),
|
||||
height: Math.max(rect.height || 600, 400)
|
||||
};
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (this.network) {
|
||||
this.network.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.network) {
|
||||
this.network.destroy();
|
||||
this.network = null;
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Geographic/Map view visualization using D3.js
|
||||
* Falls back to abstract geographic layout if no location data available
|
||||
*/
|
||||
|
||||
class GeographicGraph {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
this.init();
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'geographic';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
this.setupContainer();
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
this.g = this.svg.append('g');
|
||||
|
||||
// Add zoom and pan
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 5])
|
||||
.on('zoom', (event) => {
|
||||
this.g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(zoom);
|
||||
|
||||
// Setup resize observer
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.right = '0';
|
||||
this.container.style.bottom = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(rect.width || 800, 400),
|
||||
height: Math.max(rect.height || 600, 400)
|
||||
};
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (!this.svg) return;
|
||||
const { width, height } = this.getDimensions();
|
||||
this.svg.attr('width', width).attr('height', height);
|
||||
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
updateData(data) {
|
||||
if (!data) return;
|
||||
|
||||
const allNodes = [...(data.peers || []), ...(data.domains || [])];
|
||||
const allLinks = data.connections || [];
|
||||
const filtered = dataProcessor.filterGraph(allNodes, allLinks, this.filters);
|
||||
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateTopology(data) {
|
||||
if (!data) return;
|
||||
|
||||
const filtered = dataProcessor.filterGraph(data.nodes || [], data.edges || [], this.filters);
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateVisualization() {
|
||||
if (!this.svg || !this.nodes || this.nodes.length === 0) return;
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
|
||||
// Check if nodes have location data (lat/lng or coordinates)
|
||||
const hasLocationData = this.nodes.some(n => n.lat !== undefined || n.lng !== undefined || n.coordinates);
|
||||
|
||||
if (hasLocationData) {
|
||||
// Use actual geographic coordinates
|
||||
this.positionNodesGeographic(width, height);
|
||||
} else {
|
||||
// Use abstract geographic layout (scatter with clustering)
|
||||
this.positionNodesAbstract(width, height);
|
||||
}
|
||||
|
||||
// Clear previous
|
||||
this.g.selectAll('*').remove();
|
||||
|
||||
// Draw links
|
||||
const link = this.g.selectAll('.link')
|
||||
.data(this.links)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('class', 'link')
|
||||
.attr('x1', d => {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
return source?.x || 0;
|
||||
})
|
||||
.attr('y1', d => {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
return source?.y || 0;
|
||||
})
|
||||
.attr('x2', d => {
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
return target?.x || 0;
|
||||
})
|
||||
.attr('y2', d => {
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
return target?.y || 0;
|
||||
})
|
||||
.attr('stroke', d => d.type === 'ownership' ? '#a855f7' : '#6b7280')
|
||||
.attr('stroke-width', d => d.type === 'ownership' ? 2.5 : 2)
|
||||
.attr('opacity', 0.4);
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroups = this.g.selectAll('.node')
|
||||
.data(this.nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`)
|
||||
.style('cursor', d => d.type === 'peer' ? 'pointer' : 'default');
|
||||
|
||||
// Add circles or avatars
|
||||
nodeGroups.each(function(d) {
|
||||
const nodeGroup = d3.select(this);
|
||||
|
||||
if (d.type === 'peer' && d.profile && d.profile.avatarHash) {
|
||||
const avatarUrl = `https://global.profile/api/profile/avatar/${d.id}/24`;
|
||||
const avatarSize = 24;
|
||||
nodeGroup.append('image')
|
||||
.attr('href', avatarUrl)
|
||||
.attr('x', -avatarSize / 2)
|
||||
.attr('y', -avatarSize / 2)
|
||||
.attr('width', avatarSize)
|
||||
.attr('height', avatarSize)
|
||||
.attr('clip-path', 'url(#geographic-avatar-clip)')
|
||||
.on('error', function() {
|
||||
d3.select(this).remove();
|
||||
addCircle(nodeGroup, d);
|
||||
});
|
||||
|
||||
// Add clip path if needed
|
||||
const svg = this.ownerSVGElement;
|
||||
if (svg && !svg.querySelector('#geographic-avatar-clip')) {
|
||||
let defs = svg.querySelector('defs');
|
||||
if (!defs) {
|
||||
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
svg.insertBefore(defs, svg.firstChild);
|
||||
}
|
||||
const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
|
||||
clipPath.setAttribute('id', 'geographic-avatar-clip');
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', 0);
|
||||
circle.setAttribute('cy', 0);
|
||||
circle.setAttribute('r', avatarSize / 2);
|
||||
clipPath.appendChild(circle);
|
||||
defs.appendChild(clipPath);
|
||||
}
|
||||
} else {
|
||||
addCircle(nodeGroup, d);
|
||||
}
|
||||
});
|
||||
|
||||
function addCircle(nodeGroup, d) {
|
||||
const radius = d.isLocal ? 14 : (d.type === 'peer' ? 12 : 10);
|
||||
nodeGroup.append('circle')
|
||||
.attr('r', radius)
|
||||
.attr('fill', getNodeColor(d))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', d.isLocal ? 3 : 2);
|
||||
}
|
||||
|
||||
// Add labels
|
||||
nodeGroups.append('text')
|
||||
.attr('dy', d => {
|
||||
const radius = d.isLocal ? 14 : (d.type === 'peer' ? 12 : 10);
|
||||
return radius + 12;
|
||||
})
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '9px')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.text(d => {
|
||||
const label = d.label || d.id;
|
||||
return label.length > 10 ? label.substring(0, 10) + '...' : label;
|
||||
})
|
||||
.style('text-shadow', '1px 1px 2px rgba(0,0,0,0.8)')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
// Add click handler
|
||||
nodeGroups.on('click', (event, d) => {
|
||||
if (d.type === 'peer') {
|
||||
event.stopPropagation();
|
||||
if (window.ProfileModal) {
|
||||
window.ProfileModal.open(d.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
positionNodesGeographic(width, height) {
|
||||
// Project geographic coordinates to screen coordinates
|
||||
// Simple Mercator-like projection
|
||||
const minLat = d3.min(this.nodes, n => n.lat || (n.coordinates && n.coordinates[1]) || 0) || -90;
|
||||
const maxLat = d3.max(this.nodes, n => n.lat || (n.coordinates && n.coordinates[1]) || 90) || 90;
|
||||
const minLng = d3.min(this.nodes, n => n.lng || (n.coordinates && n.coordinates[0]) || -180) || -180;
|
||||
const maxLng = d3.max(this.nodes, n => n.lng || (n.coordinates && n.coordinates[0]) || 180) || 180;
|
||||
|
||||
const latRange = maxLat - minLat || 180;
|
||||
const lngRange = maxLng - minLng || 360;
|
||||
|
||||
this.nodes.forEach(node => {
|
||||
const lat = node.lat || (node.coordinates && node.coordinates[1]) || 0;
|
||||
const lng = node.lng || (node.coordinates && node.coordinates[0]) || 0;
|
||||
|
||||
// Simple projection
|
||||
node.x = ((lng - minLng) / lngRange) * width;
|
||||
node.y = ((maxLat - lat) / latRange) * height; // Invert Y axis
|
||||
});
|
||||
}
|
||||
|
||||
positionNodesAbstract(width, height) {
|
||||
// Abstract geographic layout: scatter with some clustering
|
||||
// Use connection-based clustering
|
||||
const clusters = this.clusterNodes();
|
||||
|
||||
clusters.forEach((cluster, i) => {
|
||||
const angle = (i / clusters.length) * 2 * Math.PI;
|
||||
const radius = Math.min(width, height) * 0.3;
|
||||
const centerX = width / 2 + Math.cos(angle) * radius;
|
||||
const centerY = height / 2 + Math.sin(angle) * radius;
|
||||
|
||||
cluster.forEach((node, j) => {
|
||||
const clusterAngle = (j / cluster.length) * 2 * Math.PI;
|
||||
const clusterRadius = 30;
|
||||
node.x = centerX + Math.cos(clusterAngle) * clusterRadius;
|
||||
node.y = centerY + Math.sin(clusterAngle) * clusterRadius;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clusterNodes() {
|
||||
// Simple clustering based on connections
|
||||
const visited = new Set();
|
||||
const clusters = [];
|
||||
|
||||
this.nodes.forEach(node => {
|
||||
if (visited.has(node.id)) return;
|
||||
|
||||
const cluster = [node];
|
||||
visited.add(node.id);
|
||||
|
||||
// Find connected nodes
|
||||
const queue = [node];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
this.links.forEach(link => {
|
||||
const source = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const target = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
|
||||
let neighbor = null;
|
||||
if (source === current.id) {
|
||||
neighbor = this.nodes.find(n => n.id === target);
|
||||
} else if (target === current.id) {
|
||||
neighbor = this.nodes.find(n => n.id === source);
|
||||
}
|
||||
|
||||
if (neighbor && !visited.has(neighbor.id)) {
|
||||
visited.add(neighbor.id);
|
||||
cluster.push(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clusters.push(cluster);
|
||||
});
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register with visualization registry
|
||||
if (typeof window !== 'undefined' && window.VisualizationRegistry) {
|
||||
window.VisualizationRegistry.register('geographic', GeographicGraph, ['domain-map']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Grid layout visualization using D3.js
|
||||
*/
|
||||
|
||||
class GridGraph {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
this.sortBy = options.sortBy || 'alphabetical'; // alphabetical, connections, type
|
||||
this.init();
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'grid';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
this.setupContainer();
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
this.g = this.svg.append('g');
|
||||
|
||||
// Add zoom
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 3])
|
||||
.on('zoom', (event) => {
|
||||
this.g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(zoom);
|
||||
|
||||
// Setup resize observer
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.right = '0';
|
||||
this.container.style.bottom = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(rect.width || 800, 400),
|
||||
height: Math.max(rect.height || 600, 400)
|
||||
};
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (!this.svg) return;
|
||||
const { width, height } = this.getDimensions();
|
||||
this.svg.attr('width', width).attr('height', height);
|
||||
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
updateData(data) {
|
||||
if (!data) return;
|
||||
|
||||
const allNodes = [...(data.peers || []), ...(data.domains || [])];
|
||||
const allLinks = data.connections || [];
|
||||
const filtered = dataProcessor.filterGraph(allNodes, allLinks, this.filters);
|
||||
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
// Sort nodes
|
||||
this.sortNodes();
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateTopology(data) {
|
||||
if (!data) return;
|
||||
|
||||
const filtered = dataProcessor.filterGraph(data.nodes || [], data.edges || [], this.filters);
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
// Sort nodes
|
||||
this.sortNodes();
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
sortNodes() {
|
||||
// Build connection count map
|
||||
const connectionCounts = new Map();
|
||||
this.nodes.forEach(node => {
|
||||
connectionCounts.set(node.id, 0);
|
||||
});
|
||||
this.links.forEach(link => {
|
||||
const source = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const target = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
connectionCounts.set(source, (connectionCounts.get(source) || 0) + 1);
|
||||
connectionCounts.set(target, (connectionCounts.get(target) || 0) + 1);
|
||||
});
|
||||
|
||||
// Sort based on sortBy option
|
||||
this.nodes.sort((a, b) => {
|
||||
if (this.sortBy === 'connections') {
|
||||
const aCount = connectionCounts.get(a.id) || 0;
|
||||
const bCount = connectionCounts.get(b.id) || 0;
|
||||
if (aCount !== bCount) {
|
||||
return bCount - aCount; // Descending
|
||||
}
|
||||
} else if (this.sortBy === 'type') {
|
||||
if (a.type !== b.type) {
|
||||
if (a.type === 'peer') return -1;
|
||||
if (b.type === 'peer') return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Alphabetical fallback
|
||||
const aLabel = (a.label || a.id).toLowerCase();
|
||||
const bLabel = (b.label || b.id).toLowerCase();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
}
|
||||
|
||||
updateVisualization() {
|
||||
if (!this.svg || !this.nodes || this.nodes.length === 0) return;
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
const padding = 40;
|
||||
const nodeSize = 30;
|
||||
const spacing = nodeSize + 20;
|
||||
|
||||
// Calculate grid dimensions
|
||||
const cols = Math.ceil(Math.sqrt(this.nodes.length));
|
||||
const rows = Math.ceil(this.nodes.length / cols);
|
||||
|
||||
// Center the grid
|
||||
const gridWidth = cols * spacing;
|
||||
const gridHeight = rows * spacing;
|
||||
const offsetX = (width - gridWidth) / 2 + padding;
|
||||
const offsetY = (height - gridHeight) / 2 + padding;
|
||||
|
||||
// Position nodes in grid
|
||||
this.nodes.forEach((node, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
node.x = offsetX + col * spacing;
|
||||
node.y = offsetY + row * spacing;
|
||||
});
|
||||
|
||||
// Clear previous
|
||||
this.g.selectAll('*').remove();
|
||||
|
||||
// Draw links
|
||||
const link = this.g.selectAll('.link')
|
||||
.data(this.links)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('class', 'link')
|
||||
.attr('x1', d => {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
return source?.x || 0;
|
||||
})
|
||||
.attr('y1', d => {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
return source?.y || 0;
|
||||
})
|
||||
.attr('x2', d => {
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
return target?.x || 0;
|
||||
})
|
||||
.attr('y2', d => {
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
return target?.y || 0;
|
||||
})
|
||||
.attr('stroke', d => d.type === 'ownership' ? '#a855f7' : '#6b7280')
|
||||
.attr('stroke-width', d => d.type === 'ownership' ? 2.5 : 2)
|
||||
.attr('opacity', 0.4);
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroups = this.g.selectAll('.node')
|
||||
.data(this.nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`)
|
||||
.style('cursor', d => d.type === 'peer' ? 'pointer' : 'default');
|
||||
|
||||
// Add circles or avatars
|
||||
nodeGroups.each(function(d) {
|
||||
const nodeGroup = d3.select(this);
|
||||
|
||||
if (d.type === 'peer' && d.profile && d.profile.avatarHash) {
|
||||
const avatarUrl = `https://global.profile/api/profile/avatar/${d.id}/24`;
|
||||
const avatarSize = 24;
|
||||
nodeGroup.append('image')
|
||||
.attr('href', avatarUrl)
|
||||
.attr('x', -avatarSize / 2)
|
||||
.attr('y', -avatarSize / 2)
|
||||
.attr('width', avatarSize)
|
||||
.attr('height', avatarSize)
|
||||
.attr('clip-path', 'url(#grid-avatar-clip)')
|
||||
.on('error', function() {
|
||||
d3.select(this).remove();
|
||||
addCircle(nodeGroup, d);
|
||||
});
|
||||
|
||||
// Add clip path if needed
|
||||
const svg = this.ownerSVGElement;
|
||||
if (svg && !svg.querySelector('#grid-avatar-clip')) {
|
||||
let defs = svg.querySelector('defs');
|
||||
if (!defs) {
|
||||
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
svg.insertBefore(defs, svg.firstChild);
|
||||
}
|
||||
const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
|
||||
clipPath.setAttribute('id', 'grid-avatar-clip');
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', 0);
|
||||
circle.setAttribute('cy', 0);
|
||||
circle.setAttribute('r', avatarSize / 2);
|
||||
clipPath.appendChild(circle);
|
||||
defs.appendChild(clipPath);
|
||||
}
|
||||
} else {
|
||||
addCircle(nodeGroup, d);
|
||||
}
|
||||
});
|
||||
|
||||
function addCircle(nodeGroup, d) {
|
||||
const radius = d.isLocal ? 14 : (d.type === 'peer' ? 12 : 10);
|
||||
nodeGroup.append('circle')
|
||||
.attr('r', radius)
|
||||
.attr('fill', getNodeColor(d))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', d.isLocal ? 3 : 2);
|
||||
}
|
||||
|
||||
// Add labels below nodes
|
||||
nodeGroups.append('text')
|
||||
.attr('dy', 20)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '9px')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.text(d => {
|
||||
const label = d.label || d.id;
|
||||
return label.length > 10 ? label.substring(0, 10) + '...' : label;
|
||||
})
|
||||
.style('text-shadow', '1px 1px 2px rgba(0,0,0,0.8)')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
// Add click handler
|
||||
nodeGroups.on('click', (event, d) => {
|
||||
if (d.type === 'peer') {
|
||||
event.stopPropagation();
|
||||
if (window.ProfileModal) {
|
||||
window.ProfileModal.open(d.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
setSortBy(sortBy) {
|
||||
this.sortBy = sortBy;
|
||||
if (this.nodes.length > 0) {
|
||||
this.sortNodes();
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register with visualization registry
|
||||
if (typeof window !== 'undefined' && window.VisualizationRegistry) {
|
||||
window.VisualizationRegistry.register('grid', GridGraph, ['network-graph', 'domain-map']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Heatmap visualization using D3.js
|
||||
* Shows connection activity/strength as color intensity
|
||||
*/
|
||||
|
||||
class HeatmapGraph {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
this.metric = options.metric || 'connections'; // connections, uptime, activity
|
||||
this.init();
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'heatmap';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
this.setupContainer();
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
this.g = this.svg.append('g');
|
||||
|
||||
// Add zoom
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 3])
|
||||
.on('zoom', (event) => {
|
||||
this.g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(zoom);
|
||||
|
||||
// Setup resize observer
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.right = '0';
|
||||
this.container.style.bottom = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(rect.width || 800, 400),
|
||||
height: Math.max(rect.height || 600, 400)
|
||||
};
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (!this.svg) return;
|
||||
const { width, height } = this.getDimensions();
|
||||
this.svg.attr('width', width).attr('height', height);
|
||||
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
updateData(data) {
|
||||
if (!data) return;
|
||||
|
||||
const allNodes = [...(data.peers || []), ...(data.domains || [])];
|
||||
const allLinks = data.connections || [];
|
||||
const filtered = dataProcessor.filterGraph(allNodes, allLinks, this.filters);
|
||||
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateTopology(data) {
|
||||
if (!data) return;
|
||||
|
||||
const filtered = dataProcessor.filterGraph(data.nodes || [], data.edges || [], this.filters);
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
calculateHeatValue(node) {
|
||||
// Calculate heat value based on selected metric
|
||||
switch (this.metric) {
|
||||
case 'connections':
|
||||
// Count connections
|
||||
return this.links.filter(link => {
|
||||
const source = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const target = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
return source === node.id || target === node.id;
|
||||
}).length;
|
||||
|
||||
case 'uptime':
|
||||
// Use uptime if available
|
||||
return node.uptime || 0;
|
||||
|
||||
case 'activity':
|
||||
// Use metrics activity if available
|
||||
return node.metrics?.messages || node.metrics?.activity || 0;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
updateVisualization() {
|
||||
if (!this.svg || !this.nodes || this.nodes.length === 0) return;
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
|
||||
// Calculate heat values
|
||||
const heatValues = this.nodes.map(node => ({
|
||||
node: node,
|
||||
value: this.calculateHeatValue(node)
|
||||
}));
|
||||
|
||||
// Create color scale
|
||||
const maxValue = d3.max(heatValues, d => d.value) || 1;
|
||||
const colorScale = d3.scaleSequential(d3.interpolateRdYlBu)
|
||||
.domain([maxValue, 0]); // Reverse: high = red, low = blue
|
||||
|
||||
// Use force simulation for layout
|
||||
const simulation = d3.forceSimulation(this.nodes)
|
||||
.force('link', d3.forceLink(this.links).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2));
|
||||
|
||||
// Clear previous
|
||||
this.g.selectAll('*').remove();
|
||||
|
||||
// Draw links with heat-based opacity
|
||||
const link = this.g.selectAll('.link')
|
||||
.data(this.links)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('class', 'link')
|
||||
.attr('stroke', '#6b7280')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('opacity', 0.3);
|
||||
|
||||
// Draw nodes with heat-based colors
|
||||
const nodeGroups = this.g.selectAll('.node')
|
||||
.data(this.nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.style('cursor', d => d.type === 'peer' ? 'pointer' : 'default');
|
||||
|
||||
nodeGroups.each(function(d) {
|
||||
const nodeGroup = d3.select(this);
|
||||
const heatValue = heatValues.find(h => h.node.id === d.id)?.value || 0;
|
||||
const color = colorScale(heatValue);
|
||||
|
||||
if (d.type === 'peer' && d.profile && d.profile.avatarHash) {
|
||||
const avatarUrl = `https://global.profile/api/profile/avatar/${d.id}/28`;
|
||||
const avatarSize = 28;
|
||||
nodeGroup.append('image')
|
||||
.attr('href', avatarUrl)
|
||||
.attr('x', -avatarSize / 2)
|
||||
.attr('y', -avatarSize / 2)
|
||||
.attr('width', avatarSize)
|
||||
.attr('height', avatarSize)
|
||||
.attr('clip-path', 'url(#heatmap-avatar-clip)')
|
||||
.on('error', function() {
|
||||
d3.select(this).remove();
|
||||
addCircle(nodeGroup, d, color);
|
||||
});
|
||||
|
||||
// Add colored ring around avatar
|
||||
nodeGroup.append('circle')
|
||||
.attr('r', avatarSize / 2 + 3)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', color)
|
||||
.attr('stroke-width', 3)
|
||||
.attr('opacity', 0.7);
|
||||
|
||||
// Add clip path if needed
|
||||
const svg = this.ownerSVGElement;
|
||||
if (svg && !svg.querySelector('#heatmap-avatar-clip')) {
|
||||
let defs = svg.querySelector('defs');
|
||||
if (!defs) {
|
||||
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
svg.insertBefore(defs, svg.firstChild);
|
||||
}
|
||||
const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
|
||||
clipPath.setAttribute('id', 'heatmap-avatar-clip');
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', 0);
|
||||
circle.setAttribute('cy', 0);
|
||||
circle.setAttribute('r', avatarSize / 2);
|
||||
clipPath.appendChild(circle);
|
||||
defs.appendChild(clipPath);
|
||||
}
|
||||
} else {
|
||||
addCircle(nodeGroup, d, color);
|
||||
}
|
||||
});
|
||||
|
||||
function addCircle(nodeGroup, d, color) {
|
||||
const radius = d.isLocal ? 16 : (d.type === 'peer' ? 14 : 12);
|
||||
nodeGroup.append('circle')
|
||||
.attr('r', radius)
|
||||
.attr('fill', color)
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', d.isLocal ? 3 : 2)
|
||||
.attr('opacity', 0.8);
|
||||
}
|
||||
|
||||
// Add labels
|
||||
nodeGroups.append('text')
|
||||
.attr('dy', d => {
|
||||
const radius = d.isLocal ? 16 : (d.type === 'peer' ? 14 : 12);
|
||||
return radius + 12;
|
||||
})
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.text(d => {
|
||||
const label = d.label || d.id;
|
||||
return label.length > 12 ? label.substring(0, 12) + '...' : label;
|
||||
})
|
||||
.style('text-shadow', '1px 1px 2px rgba(0,0,0,0.8)')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
// Update positions on simulation tick
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
return source?.x || 0;
|
||||
})
|
||||
.attr('y1', d => {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
return source?.y || 0;
|
||||
})
|
||||
.attr('x2', d => {
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
return target?.x || 0;
|
||||
})
|
||||
.attr('y2', d => {
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
return target?.y || 0;
|
||||
});
|
||||
|
||||
nodeGroups.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`);
|
||||
});
|
||||
|
||||
// Add click handler
|
||||
nodeGroups.on('click', (event, d) => {
|
||||
if (d.type === 'peer') {
|
||||
event.stopPropagation();
|
||||
if (window.ProfileModal) {
|
||||
window.ProfileModal.open(d.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store simulation for cleanup
|
||||
this.simulation = simulation;
|
||||
}
|
||||
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
setMetric(metric) {
|
||||
this.metric = metric;
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.simulation) {
|
||||
this.simulation.stop();
|
||||
this.simulation = null;
|
||||
}
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register with visualization registry
|
||||
if (typeof window !== 'undefined' && window.VisualizationRegistry) {
|
||||
window.VisualizationRegistry.register('heatmap', HeatmapGraph, ['network-graph']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Adjacency Matrix visualization using D3.js
|
||||
*/
|
||||
|
||||
class MatrixGraph {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
this.sortBy = options.sortBy || 'alphabetical'; // alphabetical, connections
|
||||
this.highlightedNode = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'matrix';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
this.setupContainer();
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
this.g = this.svg.append('g')
|
||||
.attr('transform', 'translate(80,80)');
|
||||
|
||||
// Setup resize observer
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.right = '0';
|
||||
this.container.style.bottom = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(rect.width || 800, 400),
|
||||
height: Math.max(rect.height || 600, 400)
|
||||
};
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (!this.svg) return;
|
||||
const { width, height } = this.getDimensions();
|
||||
this.svg.attr('width', width).attr('height', height);
|
||||
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
updateData(data) {
|
||||
if (!data) return;
|
||||
|
||||
const allNodes = [...(data.peers || []), ...(data.domains || [])];
|
||||
const allLinks = data.connections || [];
|
||||
const filtered = dataProcessor.filterGraph(allNodes, allLinks, this.filters);
|
||||
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
// Sort nodes
|
||||
this.sortNodes();
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateTopology(data) {
|
||||
if (!data) return;
|
||||
|
||||
const filtered = dataProcessor.filterGraph(data.nodes || [], data.edges || [], this.filters);
|
||||
this.nodes = filtered.nodes;
|
||||
this.links = filtered.edges;
|
||||
|
||||
// Sort nodes
|
||||
this.sortNodes();
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
sortNodes() {
|
||||
// Build connection count map
|
||||
const connectionCounts = new Map();
|
||||
this.nodes.forEach(node => {
|
||||
connectionCounts.set(node.id, 0);
|
||||
});
|
||||
this.links.forEach(link => {
|
||||
const source = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const target = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
connectionCounts.set(source, (connectionCounts.get(source) || 0) + 1);
|
||||
connectionCounts.set(target, (connectionCounts.get(target) || 0) + 1);
|
||||
});
|
||||
|
||||
// Sort based on sortBy option
|
||||
this.nodes.sort((a, b) => {
|
||||
if (this.sortBy === 'connections') {
|
||||
const aCount = connectionCounts.get(a.id) || 0;
|
||||
const bCount = connectionCounts.get(b.id) || 0;
|
||||
if (aCount !== bCount) {
|
||||
return bCount - aCount; // Descending
|
||||
}
|
||||
}
|
||||
|
||||
// Alphabetical fallback
|
||||
const aLabel = (a.label || a.id).toLowerCase();
|
||||
const bLabel = (b.label || b.id).toLowerCase();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
}
|
||||
|
||||
updateVisualization() {
|
||||
if (!this.svg || !this.nodes || this.nodes.length === 0) return;
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
const margin = 80;
|
||||
const matrixSize = Math.min(width - margin * 2, height - margin * 2);
|
||||
const cellSize = matrixSize / this.nodes.length;
|
||||
|
||||
// Build adjacency matrix
|
||||
const matrix = this.buildMatrix();
|
||||
|
||||
// Clear previous
|
||||
this.g.selectAll('*').remove();
|
||||
|
||||
// Create scales
|
||||
const xScale = d3.scaleBand()
|
||||
.domain(this.nodes.map(n => n.id))
|
||||
.range([0, matrixSize])
|
||||
.padding(0.05);
|
||||
|
||||
const yScale = d3.scaleBand()
|
||||
.domain(this.nodes.map(n => n.id))
|
||||
.range([0, matrixSize])
|
||||
.padding(0.05);
|
||||
|
||||
// Color scale for connection strength
|
||||
const colorScale = d3.scaleSequential(d3.interpolateBlues)
|
||||
.domain([0, d3.max(matrix, d => d.value) || 1]);
|
||||
|
||||
// Draw cells
|
||||
const cells = this.g.selectAll('.cell')
|
||||
.data(matrix)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.attr('class', 'cell')
|
||||
.attr('x', d => xScale(d.source))
|
||||
.attr('y', d => yScale(d.target))
|
||||
.attr('width', xScale.bandwidth())
|
||||
.attr('height', yScale.bandwidth())
|
||||
.attr('fill', d => {
|
||||
if (d.value > 0) {
|
||||
return colorScale(d.value);
|
||||
}
|
||||
return '#1f2937'; // Dark background for no connection
|
||||
})
|
||||
.attr('stroke', '#374151')
|
||||
.attr('stroke-width', 0.5)
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseover', (event, d) => {
|
||||
this.highlightedNode = d.source;
|
||||
this.updateHighlight();
|
||||
})
|
||||
.on('mouseout', () => {
|
||||
this.highlightedNode = null;
|
||||
this.updateHighlight();
|
||||
})
|
||||
.on('click', (event, d) => {
|
||||
if (d.value > 0) {
|
||||
const sourceNode = this.nodes.find(n => n.id === d.source);
|
||||
const targetNode = this.nodes.find(n => n.id === d.target);
|
||||
if (sourceNode && sourceNode.type === 'peer' && window.ProfileModal) {
|
||||
window.ProfileModal.open(d.source);
|
||||
} else if (targetNode && targetNode.type === 'peer' && window.ProfileModal) {
|
||||
window.ProfileModal.open(d.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add labels on X axis
|
||||
this.g.selectAll('.x-label')
|
||||
.data(this.nodes)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'x-label')
|
||||
.attr('x', d => xScale(d.id) + xScale.bandwidth() / 2)
|
||||
.attr('y', matrixSize + 15)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '9px')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.text(d => {
|
||||
const label = d.label || d.id;
|
||||
return label.length > 8 ? label.substring(0, 8) + '...' : label;
|
||||
})
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (event, d) => {
|
||||
if (d.type === 'peer' && window.ProfileModal) {
|
||||
window.ProfileModal.open(d.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Add labels on Y axis
|
||||
this.g.selectAll('.y-label')
|
||||
.data(this.nodes)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'y-label')
|
||||
.attr('x', -10)
|
||||
.attr('y', d => yScale(d.id) + yScale.bandwidth() / 2)
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('font-size', '9px')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.text(d => {
|
||||
const label = d.label || d.id;
|
||||
return label.length > 8 ? label.substring(0, 8) + '...' : label;
|
||||
})
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (event, d) => {
|
||||
if (d.type === 'peer' && window.ProfileModal) {
|
||||
window.ProfileModal.open(d.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateHighlight();
|
||||
}
|
||||
|
||||
buildMatrix() {
|
||||
const matrix = [];
|
||||
|
||||
// Build connection map
|
||||
const connections = new Map();
|
||||
this.links.forEach(link => {
|
||||
const source = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const target = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
const key = `${source}-${target}`;
|
||||
connections.set(key, (connections.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
// Create matrix cells
|
||||
this.nodes.forEach(source => {
|
||||
this.nodes.forEach(target => {
|
||||
const key = `${source.id}-${target.id}`;
|
||||
const value = connections.get(key) || 0;
|
||||
matrix.push({
|
||||
source: source.id,
|
||||
target: target.id,
|
||||
value: value
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
updateHighlight() {
|
||||
if (!this.highlightedNode) {
|
||||
this.g.selectAll('.cell')
|
||||
.attr('opacity', 1);
|
||||
this.g.selectAll('.x-label, .y-label')
|
||||
.attr('font-weight', 'normal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Highlight row and column
|
||||
this.g.selectAll('.cell')
|
||||
.attr('opacity', d => {
|
||||
if (d.source === this.highlightedNode || d.target === this.highlightedNode) {
|
||||
return 1;
|
||||
}
|
||||
return 0.3;
|
||||
});
|
||||
|
||||
this.g.selectAll('.x-label, .y-label')
|
||||
.attr('font-weight', d => d.id === this.highlightedNode ? 'bold' : 'normal')
|
||||
.attr('fill', d => d.id === this.highlightedNode ? '#fbbf24' : '#e5e7eb');
|
||||
}
|
||||
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
if (this.nodes.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
setSortBy(sortBy) {
|
||||
this.sortBy = sortBy;
|
||||
if (this.nodes.length > 0) {
|
||||
this.sortNodes();
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.links = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register with visualization registry
|
||||
if (typeof window !== 'undefined' && window.VisualizationRegistry) {
|
||||
window.VisualizationRegistry.register('matrix', MatrixGraph, ['network-graph']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* Network Graph Visualization using vis-network
|
||||
*/
|
||||
|
||||
class NetworkGraph {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.network = null;
|
||||
this.nodes = null;
|
||||
this.edges = null;
|
||||
this.currentLayout = 'force';
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: false,
|
||||
showOfflinePeers: false,
|
||||
searchTerm: ''
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the network graph
|
||||
*/
|
||||
init() {
|
||||
if (!this.container) {
|
||||
console.error('NetworkGraph: Container element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure container uses full height
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
this.container.style.minHeight = '0';
|
||||
|
||||
// Create data structure
|
||||
this.nodes = new vis.DataSet([]);
|
||||
this.edges = new vis.DataSet([]);
|
||||
|
||||
// Configure options
|
||||
const options = this.getDefaultOptions();
|
||||
|
||||
// Create network
|
||||
const data = {
|
||||
nodes: this.nodes,
|
||||
edges: this.edges
|
||||
};
|
||||
|
||||
this.network = new vis.Network(this.container, data, options);
|
||||
|
||||
// Setup event handlers
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Track if initial stabilization is complete
|
||||
this.stabilized = false;
|
||||
this.network.on('stabilizationEnd', () => {
|
||||
this.stabilized = true;
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
this.resizeHandler = () => {
|
||||
if (this.network) {
|
||||
this.network.fit();
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default vis-network options
|
||||
*/
|
||||
getDefaultOptions() {
|
||||
return {
|
||||
nodes: {
|
||||
borderWidth: 2,
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
size: 5,
|
||||
x: 2,
|
||||
y: 2
|
||||
},
|
||||
font: {
|
||||
color: '#f1f5f9', // var(--text-primary)
|
||||
size: 14,
|
||||
face: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", Roboto, sans-serif'
|
||||
},
|
||||
scaling: {
|
||||
min: 10,
|
||||
max: 50
|
||||
}
|
||||
},
|
||||
edges: {
|
||||
width: 2,
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0,0,0,0.2)',
|
||||
size: 3
|
||||
},
|
||||
smooth: {
|
||||
type: 'continuous',
|
||||
roundness: 0.5
|
||||
}
|
||||
},
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 200,
|
||||
fit: true
|
||||
},
|
||||
barnesHut: {
|
||||
gravitationalConstant: -2000,
|
||||
centralGravity: 0.1,
|
||||
springLength: 200,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0.5
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
dragNodes: true,
|
||||
dragView: true,
|
||||
zoomView: true,
|
||||
hover: true,
|
||||
tooltipDelay: 200,
|
||||
selectConnectedEdges: true
|
||||
},
|
||||
layout: {
|
||||
improvedLayout: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers
|
||||
*/
|
||||
setupEventHandlers() {
|
||||
if (!this.network) return;
|
||||
|
||||
// Handle node clicks - open ProfileModal for peer nodes
|
||||
this.network.on('click', (params) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const nodeId = params.nodes[0];
|
||||
const node = this.nodes.get(nodeId);
|
||||
|
||||
if (node && node.type === 'peer' && window.ProfileModal) {
|
||||
window.ProfileModal.open(nodeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle double click - fit to screen
|
||||
this.network.on('doubleClick', (params) => {
|
||||
if (params.nodes.length === 0) {
|
||||
this.network.fit();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle hover for highlighting
|
||||
this.network.on('hoverNode', (params) => {
|
||||
this.container.style.cursor = 'pointer';
|
||||
});
|
||||
|
||||
this.network.on('blurNode', () => {
|
||||
this.container.style.cursor = 'default';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update data from topology
|
||||
*/
|
||||
updateData(topologyData) {
|
||||
if (!topologyData) return;
|
||||
|
||||
// Convert to vis-network format
|
||||
const visData = dataProcessor.convertToVisNetworkFormat(topologyData);
|
||||
|
||||
// Apply filters
|
||||
const filtered = this.applyFilters(visData);
|
||||
|
||||
// Check if this is the first load (no nodes yet)
|
||||
const isInitialLoad = this.nodes.length === 0;
|
||||
|
||||
if (isInitialLoad) {
|
||||
// Initial load - add all nodes and edges
|
||||
this.nodes.clear();
|
||||
this.nodes.add(filtered.nodes);
|
||||
this.edges.clear();
|
||||
this.edges.add(filtered.edges);
|
||||
// Apply layout on initial load with stabilization
|
||||
this.applyLayout(this.currentLayout, true);
|
||||
} else {
|
||||
// Incremental update - preserve positions and only update changes
|
||||
// Temporarily disable physics during update to prevent bouncing
|
||||
if (this.network && this.currentLayout === 'force') {
|
||||
this.network.setOptions({ physics: { enabled: false } });
|
||||
}
|
||||
|
||||
this.updateNodesIncremental(filtered.nodes);
|
||||
this.updateEdgesIncremental(filtered.edges);
|
||||
|
||||
// Re-enable physics smoothly after a short delay
|
||||
if (this.network && this.currentLayout === 'force') {
|
||||
setTimeout(() => {
|
||||
if (this.network) {
|
||||
this.network.setOptions({
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: { enabled: false } // No stabilization on updates
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update nodes incrementally, preserving positions
|
||||
*/
|
||||
updateNodesIncremental(newNodes) {
|
||||
const existingNodeIds = new Set(this.nodes.getIds());
|
||||
const newNodeIds = new Set(newNodes.map(n => n.id));
|
||||
|
||||
// Get current positions to preserve them
|
||||
const positions = {};
|
||||
if (this.network) {
|
||||
const existingIds = this.nodes.getIds();
|
||||
existingIds.forEach(id => {
|
||||
const pos = this.network.getPositions([id]);
|
||||
if (pos && pos[id]) {
|
||||
positions[id] = pos[id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove nodes that no longer exist
|
||||
const toRemove = [];
|
||||
existingNodeIds.forEach(id => {
|
||||
if (!newNodeIds.has(id)) {
|
||||
toRemove.push(id);
|
||||
}
|
||||
});
|
||||
if (toRemove.length > 0) {
|
||||
this.nodes.remove(toRemove);
|
||||
}
|
||||
|
||||
// Update or add nodes
|
||||
const toUpdate = [];
|
||||
const toAdd = [];
|
||||
|
||||
newNodes.forEach(newNode => {
|
||||
if (existingNodeIds.has(newNode.id)) {
|
||||
// Node exists - update it but preserve position
|
||||
const existingNode = this.nodes.get(newNode.id);
|
||||
const update = { ...newNode };
|
||||
|
||||
// Preserve position if it exists
|
||||
if (positions[newNode.id]) {
|
||||
update.x = positions[newNode.id].x;
|
||||
update.fixed = { x: false, y: false }; // Allow physics to adjust slightly
|
||||
}
|
||||
|
||||
toUpdate.push(update);
|
||||
} else {
|
||||
// New node - add it
|
||||
toAdd.push(newNode);
|
||||
}
|
||||
});
|
||||
|
||||
if (toUpdate.length > 0) {
|
||||
this.nodes.update(toUpdate);
|
||||
}
|
||||
if (toAdd.length > 0) {
|
||||
this.nodes.add(toAdd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update edges incrementally
|
||||
*/
|
||||
updateEdgesIncremental(newEdges) {
|
||||
const existingEdgeKeys = new Set();
|
||||
const edgeIdMap = new Map(); // Map edge key to edge id
|
||||
|
||||
this.edges.forEach(edge => {
|
||||
const key = `${edge.from}-${edge.to}`;
|
||||
existingEdgeKeys.add(key);
|
||||
edgeIdMap.set(key, edge.id);
|
||||
});
|
||||
|
||||
const newEdgeKeys = new Set();
|
||||
newEdges.forEach(edge => {
|
||||
const key = `${edge.from}-${edge.to}`;
|
||||
newEdgeKeys.add(key);
|
||||
});
|
||||
|
||||
// Remove edges that no longer exist
|
||||
const toRemove = [];
|
||||
existingEdgeKeys.forEach(key => {
|
||||
if (!newEdgeKeys.has(key)) {
|
||||
const edgeId = edgeIdMap.get(key);
|
||||
if (edgeId !== undefined) {
|
||||
toRemove.push(edgeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (toRemove.length > 0) {
|
||||
this.edges.remove(toRemove);
|
||||
}
|
||||
|
||||
// Add new edges
|
||||
const toAdd = [];
|
||||
newEdges.forEach(edge => {
|
||||
const key = `${edge.from}-${edge.to}`;
|
||||
if (!existingEdgeKeys.has(key)) {
|
||||
toAdd.push(edge);
|
||||
}
|
||||
});
|
||||
if (toAdd.length > 0) {
|
||||
this.edges.add(toAdd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to data
|
||||
*/
|
||||
applyFilters(visData) {
|
||||
let filteredNodes = [...visData.nodes];
|
||||
let filteredEdges = [...visData.edges];
|
||||
|
||||
// Filter by connection status
|
||||
if (!this.filters.showConnected) {
|
||||
filteredNodes = filteredNodes.filter(node => {
|
||||
// Keep domains, keep disconnected peers if showOfflinePeers is true
|
||||
if (node.type !== 'peer') return true;
|
||||
return !node.connected || (this.filters.showOfflinePeers && !node.connected);
|
||||
});
|
||||
} else if (!this.filters.showOfflinePeers) {
|
||||
filteredNodes = filteredNodes.filter(node => {
|
||||
if (node.type !== 'peer') return true;
|
||||
return node.connected;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by domains
|
||||
if (!this.filters.showDomains) {
|
||||
filteredNodes = filteredNodes.filter(node => node.type !== 'domain');
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (this.filters.searchTerm) {
|
||||
const searchLower = this.filters.searchTerm.toLowerCase();
|
||||
filteredNodes = filteredNodes.filter(node => {
|
||||
const label = (node.label || '').toLowerCase();
|
||||
const id = (node.id || '').toLowerCase();
|
||||
return label.includes(searchLower) || id.includes(searchLower);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter edges to only include connections between visible nodes
|
||||
const visibleNodeIds = new Set(filteredNodes.map(n => n.id));
|
||||
filteredEdges = filteredEdges.filter(edge => {
|
||||
return visibleNodeIds.has(edge.from) && visibleNodeIds.has(edge.to);
|
||||
});
|
||||
|
||||
return { nodes: filteredNodes, edges: filteredEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply layout
|
||||
*/
|
||||
applyLayout(layout, allowStabilization = false) {
|
||||
this.currentLayout = layout;
|
||||
|
||||
if (!this.network) return;
|
||||
|
||||
const options = this.getDefaultOptions();
|
||||
|
||||
switch (layout) {
|
||||
case 'hierarchical':
|
||||
options.layout = {
|
||||
hierarchical: {
|
||||
direction: 'UD',
|
||||
sortMethod: 'directed',
|
||||
levelSeparation: 150,
|
||||
nodeSpacing: 200,
|
||||
treeSpacing: 200,
|
||||
blockShifting: true,
|
||||
edgeMinimization: true,
|
||||
parentCentralization: true
|
||||
}
|
||||
};
|
||||
options.physics = {
|
||||
enabled: false
|
||||
};
|
||||
break;
|
||||
|
||||
case 'force':
|
||||
default:
|
||||
options.physics = {
|
||||
enabled: true,
|
||||
stabilization: {
|
||||
enabled: allowStabilization, // Only stabilize on initial load
|
||||
iterations: allowStabilization ? 200 : 0,
|
||||
fit: allowStabilization
|
||||
},
|
||||
barnesHut: {
|
||||
gravitationalConstant: -2000,
|
||||
centralGravity: 0.1,
|
||||
springLength: 200,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0.5
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
this.network.setOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set layout
|
||||
*/
|
||||
setLayout(layout) {
|
||||
this.applyLayout(layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filters and re-apply
|
||||
*/
|
||||
setFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
|
||||
// Re-apply filters to current data
|
||||
if (this.nodes.length > 0 || this.edges.length > 0) {
|
||||
const currentData = {
|
||||
nodes: this.nodes.get(),
|
||||
edges: this.edges.get()
|
||||
};
|
||||
const filtered = this.applyFilters(currentData);
|
||||
|
||||
this.nodes.clear();
|
||||
this.nodes.add(filtered.nodes);
|
||||
this.edges.clear();
|
||||
this.edges.add(filtered.edges);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peer properties (for real-time updates)
|
||||
*/
|
||||
updatePeerProperties(peers) {
|
||||
if (!this.network || !peers) return;
|
||||
|
||||
for (const peer of peers) {
|
||||
const node = this.nodes.get(peer.id);
|
||||
if (node) {
|
||||
const updates = {};
|
||||
|
||||
// Update connection status color
|
||||
if (peer.connected !== node.connected) {
|
||||
if (peer.isLocal) {
|
||||
updates.color = {
|
||||
background: '#6366f1',
|
||||
border: '#4f46e5',
|
||||
highlight: { background: '#818cf8', border: '#6366f1' }
|
||||
};
|
||||
} else if (peer.connected) {
|
||||
updates.color = {
|
||||
background: '#10b981',
|
||||
border: '#0ea66e',
|
||||
highlight: { background: '#34d399', border: '#10b981' }
|
||||
};
|
||||
} else {
|
||||
updates.color = {
|
||||
background: '#64748b',
|
||||
border: '#475569',
|
||||
highlight: { background: '#94a3b8', border: '#64748b' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update label if profile changed
|
||||
if (peer.profile && peer.profile.displayName && node.label !== peer.profile.displayName) {
|
||||
updates.label = peer.profile.displayName;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
this.nodes.update({ id: peer.id, ...updates });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update topology
|
||||
*/
|
||||
updateTopology(topologyData) {
|
||||
this.updateData(topologyData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the network
|
||||
*/
|
||||
destroy() {
|
||||
if (this.resizeHandler) {
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
this.resizeHandler = null;
|
||||
}
|
||||
if (this.network) {
|
||||
this.network.destroy();
|
||||
this.network = null;
|
||||
}
|
||||
if (this.nodes) {
|
||||
this.nodes.clear();
|
||||
this.nodes = null;
|
||||
}
|
||||
if (this.edges) {
|
||||
this.edges.clear();
|
||||
this.edges = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Peer details visualization component
|
||||
*/
|
||||
|
||||
class PeerDetails {
|
||||
constructor() {
|
||||
// This is handled by app.js
|
||||
}
|
||||
}
|
||||
|
||||
// Peer details functionality is integrated into app.js
|
||||
// This file exists for consistency with the architecture
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Visualization Registry
|
||||
* Central registry for managing visualization types and their lifecycle
|
||||
*/
|
||||
|
||||
const VisualizationRegistry = {
|
||||
// Map of visualization type to class
|
||||
visualizations: new Map(),
|
||||
|
||||
// Map of view to available visualization types
|
||||
viewTypes: {
|
||||
'network-graph': ['force-directed', 'hierarchical', 'circular', 'grid', 'matrix', 'heatmap'],
|
||||
'domain-map': ['force-directed', 'hierarchical', 'circular', 'grid', 'geographic'],
|
||||
'system-overview': ['dashboard', 'timeline']
|
||||
},
|
||||
|
||||
// Default visualization types per view
|
||||
defaultTypes: {
|
||||
'network-graph': 'force-directed',
|
||||
'domain-map': 'force-directed',
|
||||
'system-overview': 'dashboard'
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a visualization class
|
||||
* @param {string} type - Visualization type identifier
|
||||
* @param {Function} VisualizationClass - Class constructor
|
||||
* @param {Array<string>} views - Views this visualization can be used in
|
||||
*/
|
||||
register(type, VisualizationClass, views = []) {
|
||||
if (typeof VisualizationClass !== 'function') {
|
||||
throw new Error(`Visualization class for type "${type}" must be a constructor function`);
|
||||
}
|
||||
|
||||
this.visualizations.set(type, {
|
||||
Class: VisualizationClass,
|
||||
views: views,
|
||||
type: type
|
||||
});
|
||||
|
||||
console.log(`[VisualizationRegistry] Registered visualization type: ${type} for views: ${views.join(', ')}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a visualization instance
|
||||
* @param {string} type - Visualization type identifier
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {Object} options - Options to pass to constructor
|
||||
* @returns {Object} Visualization instance
|
||||
*/
|
||||
create(type, container, options = {}) {
|
||||
const registration = this.visualizations.get(type);
|
||||
|
||||
if (!registration) {
|
||||
throw new Error(`Visualization type "${type}" is not registered`);
|
||||
}
|
||||
|
||||
if (!container) {
|
||||
throw new Error('Container element is required');
|
||||
}
|
||||
|
||||
const VisualizationClass = registration.Class;
|
||||
const instance = new VisualizationClass(container, options);
|
||||
|
||||
// Ensure instance has getType method
|
||||
if (typeof instance.getType !== 'function') {
|
||||
instance.getType = () => type;
|
||||
}
|
||||
|
||||
return instance;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get available visualization types for a view
|
||||
* @param {string} view - View name
|
||||
* @returns {Array<string>} Available visualization types
|
||||
*/
|
||||
getAvailableTypes(view) {
|
||||
if (!view) {
|
||||
return Array.from(this.visualizations.keys());
|
||||
}
|
||||
|
||||
const types = this.viewTypes[view] || [];
|
||||
|
||||
// Filter to only return registered types
|
||||
return types.filter(type => this.visualizations.has(type));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get default visualization type for a view
|
||||
* @param {string} view - View name
|
||||
* @returns {string} Default visualization type
|
||||
*/
|
||||
getDefaultType(view) {
|
||||
return this.defaultTypes[view] || 'force-directed';
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a visualization type is available for a view
|
||||
* @param {string} type - Visualization type
|
||||
* @param {string} view - View name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAvailableForView(type, view) {
|
||||
const types = this.getAvailableTypes(view);
|
||||
return types.includes(type);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all registered visualization types
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
getAllTypes() {
|
||||
return Array.from(this.visualizations.keys());
|
||||
}
|
||||
};
|
||||
|
||||
// Make registry available globally
|
||||
if (typeof window !== 'undefined') {
|
||||
window.VisualizationRegistry = VisualizationRegistry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Timeline/Chronological visualization using D3.js
|
||||
*/
|
||||
|
||||
class TimelineGraph {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.events = [];
|
||||
this.filters = {
|
||||
showConnected: true,
|
||||
showDomains: true,
|
||||
searchTerm: ''
|
||||
};
|
||||
this.timeRange = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'timeline';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container.innerHTML = '';
|
||||
this.setupContainer();
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
this.g = this.svg.append('g')
|
||||
.attr('transform', 'translate(60,20)');
|
||||
|
||||
// Setup resize observer
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.right = '0';
|
||||
this.container.style.bottom = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.max(rect.width || 800, 400),
|
||||
height: Math.max(rect.height || 600, 400)
|
||||
};
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (!this.svg) return;
|
||||
const { width, height } = this.getDimensions();
|
||||
this.svg.attr('width', width).attr('height', height);
|
||||
|
||||
if (this.events.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
updateData(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Extract timeline events from data
|
||||
this.events = this.extractEvents(data);
|
||||
this.nodes = [...(data.peers || []), ...(data.domains || [])];
|
||||
|
||||
this.updateVisualization();
|
||||
}
|
||||
|
||||
updateTopology(data) {
|
||||
// Timeline doesn't use topology updates
|
||||
this.updateData(data);
|
||||
}
|
||||
|
||||
extractEvents(data) {
|
||||
const events = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Extract peer connection/disconnection events
|
||||
if (data.peers) {
|
||||
data.peers.forEach(peer => {
|
||||
if (peer.connectTime) {
|
||||
events.push({
|
||||
time: peer.connectTime,
|
||||
type: 'connect',
|
||||
peerId: peer.id,
|
||||
peer: peer
|
||||
});
|
||||
}
|
||||
if (peer.history && Array.isArray(peer.history)) {
|
||||
peer.history.forEach(entry => {
|
||||
if (entry.type === 'connect' || entry.type === 'disconnect') {
|
||||
events.push({
|
||||
time: entry.timestamp || now,
|
||||
type: entry.type,
|
||||
peerId: peer.id,
|
||||
peer: peer
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract domain registration events (if available)
|
||||
if (data.domains) {
|
||||
data.domains.forEach(domain => {
|
||||
if (domain.registeredAt) {
|
||||
events.push({
|
||||
time: domain.registeredAt,
|
||||
type: 'domain-register',
|
||||
domainId: domain.id,
|
||||
domain: domain
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
events.sort((a, b) => a.time - b.time);
|
||||
|
||||
// Set time range
|
||||
if (events.length > 0) {
|
||||
this.timeRange = {
|
||||
min: events[0].time,
|
||||
max: events[events.length - 1].time || now
|
||||
};
|
||||
} else {
|
||||
// Default to last 24 hours
|
||||
this.timeRange = {
|
||||
min: now - 24 * 60 * 60 * 1000,
|
||||
max: now
|
||||
};
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
updateVisualization() {
|
||||
if (!this.svg || !this.events || this.events.length === 0) return;
|
||||
|
||||
const { width, height } = this.getDimensions();
|
||||
const margin = { top: 20, right: 20, bottom: 40, left: 80 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
// Clear previous
|
||||
this.g.selectAll('*').remove();
|
||||
|
||||
// Get unique peers for Y-axis
|
||||
const peerIds = [...new Set(this.events.map(e => e.peerId).filter(Boolean))];
|
||||
const peerCount = peerIds.length || 1;
|
||||
const peerSpacing = chartHeight / Math.max(peerCount, 1);
|
||||
|
||||
// Create time scale
|
||||
const timeScale = d3.scaleTime()
|
||||
.domain([this.timeRange.min, this.timeRange.max])
|
||||
.range([0, chartWidth]);
|
||||
|
||||
// Create peer scale
|
||||
const peerScale = d3.scaleBand()
|
||||
.domain(peerIds)
|
||||
.range([0, chartHeight])
|
||||
.padding(0.2);
|
||||
|
||||
// Draw time axis
|
||||
const timeAxis = d3.axisBottom(timeScale)
|
||||
.ticks(10)
|
||||
.tickFormat(d3.timeFormat('%H:%M'));
|
||||
|
||||
this.g.append('g')
|
||||
.attr('transform', `translate(0,${chartHeight})`)
|
||||
.call(timeAxis)
|
||||
.selectAll('text')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.attr('font-size', '10px');
|
||||
|
||||
this.g.append('g')
|
||||
.call(timeAxis)
|
||||
.selectAll('line, path')
|
||||
.attr('stroke', '#6b7280');
|
||||
|
||||
// Draw peer axis
|
||||
const peerAxis = d3.axisLeft(peerScale)
|
||||
.tickFormat(id => {
|
||||
const peer = this.nodes.find(n => n.id === id);
|
||||
return peer ? (peer.label || id.substring(0, 12) + '...') : id.substring(0, 12) + '...';
|
||||
});
|
||||
|
||||
this.g.append('g')
|
||||
.call(peerAxis)
|
||||
.selectAll('text')
|
||||
.attr('fill', '#e5e7eb')
|
||||
.attr('font-size', '9px');
|
||||
|
||||
this.g.append('g')
|
||||
.call(peerAxis)
|
||||
.selectAll('line, path')
|
||||
.attr('stroke', '#6b7280');
|
||||
|
||||
// Draw events
|
||||
const eventGroups = this.g.selectAll('.event')
|
||||
.data(this.events)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'event')
|
||||
.attr('transform', d => {
|
||||
const x = timeScale(d.time);
|
||||
const y = peerScale(d.peerId) || 0;
|
||||
return `translate(${x},${y + peerScale.bandwidth() / 2})`;
|
||||
});
|
||||
|
||||
eventGroups.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', d => {
|
||||
if (d.type === 'connect') return '#10b981';
|
||||
if (d.type === 'disconnect') return '#ef4444';
|
||||
if (d.type === 'domain-register') return '#a855f7';
|
||||
return '#6b7280';
|
||||
})
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
// Add tooltips
|
||||
eventGroups.append('title')
|
||||
.text(d => {
|
||||
const timeStr = new Date(d.time).toLocaleString();
|
||||
if (d.type === 'connect') return `Connected: ${timeStr}`;
|
||||
if (d.type === 'disconnect') return `Disconnected: ${timeStr}`;
|
||||
if (d.type === 'domain-register') return `Domain registered: ${timeStr}`;
|
||||
return `${d.type}: ${timeStr}`;
|
||||
});
|
||||
|
||||
// Add click handler
|
||||
eventGroups.on('click', (event, d) => {
|
||||
if (d.peerId && window.ProfileModal) {
|
||||
window.ProfileModal.open(d.peerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(filters) {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
// Filters don't affect timeline much, but we can filter events
|
||||
if (this.events.length > 0) {
|
||||
this.updateVisualization();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
this.svg = null;
|
||||
this.g = null;
|
||||
this.nodes = [];
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register with visualization registry
|
||||
if (typeof window !== 'undefined' && window.VisualizationRegistry) {
|
||||
window.VisualizationRegistry.register('timeline', TimelineGraph, ['system-overview']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* WebSocket client for real-time updates
|
||||
*/
|
||||
|
||||
class WebSocketClient {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.listeners = new Map();
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// WebSocket path for plugins is handled by the plugin system
|
||||
// The plugin handler will route /ws to the plugin's WebSocket server
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected');
|
||||
this.updateStatus(true);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
} catch (err) {
|
||||
// Error parsing message - silently continue
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this.emit('error', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.isConnected = false;
|
||||
this.updateStatus(false);
|
||||
this.emit('disconnected');
|
||||
this.attemptReconnect();
|
||||
};
|
||||
} catch (err) {
|
||||
this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
*/
|
||||
handleMessage(message) {
|
||||
const { type, data, timestamp } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'init':
|
||||
this.emit('init', data);
|
||||
break;
|
||||
case 'system-update':
|
||||
this.emit('system-update', data);
|
||||
break;
|
||||
case 'topology-update':
|
||||
this.emit('topology-update', data);
|
||||
break;
|
||||
case 'metrics-update':
|
||||
this.emit('metrics-update', data);
|
||||
break;
|
||||
case 'system':
|
||||
this.emit('system', data);
|
||||
break;
|
||||
case 'topology':
|
||||
this.emit('topology', data);
|
||||
break;
|
||||
case 'metrics':
|
||||
this.emit('metrics', data);
|
||||
break;
|
||||
default:
|
||||
// Unknown message type - silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server
|
||||
*/
|
||||
send(type, data = {}) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type, ...data }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request system state
|
||||
*/
|
||||
requestSystem() {
|
||||
this.send('request-system');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request topology
|
||||
*/
|
||||
requestTopology() {
|
||||
this.send('request-topology');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request metrics
|
||||
*/
|
||||
requestMetrics() {
|
||||
this.send('request-metrics');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event listener
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (this.listeners.has(event)) {
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event
|
||||
*/
|
||||
emit(event, data) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
// Error in event listener - silently continue
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect
|
||||
*/
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
} else {
|
||||
this.emit('reconnect-failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status indicator
|
||||
*/
|
||||
updateStatus(connected) {
|
||||
const indicator = document.getElementById('statusIndicator');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
if (indicator) {
|
||||
if (connected) {
|
||||
indicator.className = 'w-3 h-3 rounded-full bg-green-500 animate-pulse';
|
||||
} else {
|
||||
indicator.className = 'w-3 h-3 rounded-full bg-red-500';
|
||||
}
|
||||
}
|
||||
|
||||
if (statusText) {
|
||||
statusText.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
const wsClient = new WebSocketClient();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "peer.visualize",
|
||||
"short_name": "Visualize",
|
||||
"description": "Interactive real-time visualization of P2NS peer connections and system architecture",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "developer"],
|
||||
"lang": "en"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user