This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
+685
View File
@@ -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();
+165
View File
@@ -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,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();