// Events Component - Visualizes websocket events as a graph class EventComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); this.svg = null; this.simulation = null; this.zoom = null; this.width = 0; this.height = 0; this.isInitialized = false; // Center node data - will be initialized with proper coordinates later this.centerNode = { id: 'center', type: 'center', label: 'Events', x: 0, y: 0 }; // Track nodes for D3 this.graphNodes = []; this.graphLinks = []; // Track recent events to trigger animations this.lastSeenEvents = new Set(); } updateDimensions() { const container = this.findElement('#events-graph-container'); if (!container) { logger.error('EventComponent: Container not found!'); return; } const rect = container.getBoundingClientRect(); this.width = rect.width || 1400; this.height = rect.height || 800; this.width = Math.max(this.width, 800); this.height = Math.max(this.height, 600); } mount() { if (this.isMounted) return; if (!this.isInitialized) { this.initialize(); } this.isMounted = true; this.setupEventListeners(); this.setupViewModelListeners(); this.render(); } initialize() { if (this.isInitialized) return; // Create SVG const container = this.findElement('#events-graph-container'); if (!container) { logger.error('EventComponent: Container not found'); return; } this.updateDimensions(); // Clear container container.innerHTML = ''; // Create SVG with D3 - match topology view style this.svg = d3.select(container) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('viewBox', `0 0 ${this.width} ${this.height}`) .style('border', '1px solid rgba(255, 255, 255, 0.1)') .style('border-radius', '12px'); // Create zoom behavior this.zoom = d3.zoom() .scaleExtent([0.5, 5]) .on('zoom', (event) => { this.svg.select('g').attr('transform', event.transform); }); this.svg.call(this.zoom); // Create main group for graph elements const mainGroup = this.svg.append('g'); // Apply initial zoom to show the graph more zoomed in mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Initialize simulation this.simulation = d3.forceSimulation() .force('link', d3.forceLink().id(d => d.id).distance(100)) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(this.width / 2, this.height / 2)) .force('collision', d3.forceCollide().radius(35)) .alphaDecay(0.0228) // Slower decay to allow simulation to run longer .velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement this.isInitialized = true; } setupEventListeners() { // Handle window resize this.addEventListener(window, 'resize', () => { if (this.resizeTimeout) clearTimeout(this.resizeTimeout); this.resizeTimeout = setTimeout(() => { this.handleResize(); }, 250); }); } handleResize() { const container = this.findElement('#events-graph-container'); if (container && this.svg) { this.updateDimensions(); this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`); if (this.simulation) { this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2)); this.simulation.alpha(0.3).restart(); } } } setupViewModelListeners() { // Listen for events changes this.subscribeToProperty('events', (newValue, previousValue) => { // Check for new or updated events to animate if (newValue && newValue.size > 0 && previousValue) { for (const [topic, data] of newValue) { const prevData = previousValue.get(topic); if (!prevData || data.count > prevData.count) { // New event or count increased - trigger animation this.animateEventMessage(topic, data); } } } // Remove empty state if it exists if (this.svg) { const g = this.svg.select('g'); if (!g.empty()) { g.selectAll('.empty-state').remove(); } } this.updateGraph(); }); // Listen for lastUpdateTime changes this.subscribeToProperty('lastUpdateTime', () => { this.updateGraph(); }); } updateGraph() { if (!this.isInitialized || !this.svg) { return; } const events = this.viewModel.get('events'); // Get existing nodes from simulation to preserve positions const existingNodesMap = new Map(); if (this.simulation && this.simulation.nodes) { this.simulation.nodes().forEach(node => { existingNodesMap.set(node.id, node); }); } // Create nodes map with center node (always present) const nodesMap = new Map(); const centerX = this.width / 2; const centerY = this.height / 2; const centerNodeWithPos = { ...this.centerNode, x: centerX, y: centerY, fx: centerX, // Fix x position fy: centerY // Fix y position }; nodesMap.set('center', centerNodeWithPos); // Only process events if they exist if (events && events.size > 0) { // First pass: create all unique topic part nodes for (const [topic, data] of events) { data.parts.forEach((part, index) => { if (!nodesMap.has(part)) { const existingNode = existingNodesMap.get(part); if (existingNode && existingNode.x && existingNode.y) { // Preserve existing position, including fixed state for center node nodesMap.set(part, { id: part, type: 'topic', label: part, count: 0, x: existingNode.x, y: existingNode.y, fx: existingNode.fx, fy: existingNode.fy }); } else { // Initialize new node with random position const angle = Math.random() * Math.PI * 2; const distance = 150 + Math.random() * 100; nodesMap.set(part, { id: part, type: 'topic', label: part, count: 0, x: (this.width / 2) + Math.cos(angle) * distance, y: (this.height / 2) + Math.sin(angle) * distance, fx: null, fy: null }); } } }); } // Second pass: count occurrences of each part for (const [topic, data] of events) { data.parts.forEach(part => { const node = nodesMap.get(part); if (node) { node.count += data.count; } }); } } this.graphNodes = Array.from(nodesMap.values()); // Build links as chains for each topic // For "cluster/update", create: center -> cluster -> update this.graphLinks = []; const linkSet = new Set(); // Track links to avoid duplicates if (events && events.size > 0) { for (const [topic, data] of events) { const parts = data.parts; if (parts.length === 0) continue; // Connect center to first part const firstPart = parts[0]; const centerToFirst = `center-${firstPart}`; if (!linkSet.has(centerToFirst)) { this.graphLinks.push({ source: 'center', target: firstPart, type: 'center-link', count: data.count, topic: topic }); linkSet.add(centerToFirst); } else { // Update count for existing link const existingLink = this.graphLinks.find(l => `${l.source}-${l.target}` === centerToFirst ); if (existingLink) { existingLink.count += data.count; } } // Connect each part to the next (creating a chain) for (let i = 0; i < parts.length - 1; i++) { const source = parts[i]; const target = parts[i + 1]; const linkKey = `${source}-${target}`; if (!linkSet.has(linkKey)) { this.graphLinks.push({ source: source, target: target, type: 'topic-link', topic: topic, count: data.count }); linkSet.add(linkKey); } else { // Update count for existing link const existingLink = this.graphLinks.find(l => `${l.source}-${l.target}` === linkKey ); if (existingLink) { existingLink.count += data.count; } } } } } // Update D3 simulation this.updateSimulation(); } updateSimulation() { const g = this.svg.select('g'); if (!g || g.empty()) { return; } // Update links const links = g.selectAll('.link') .data(this.graphLinks, d => `${d.source}-${d.target}`); // Remove old links links.exit() .transition() .duration(300) .style('opacity', 0) .remove(); // Add new links const linksEnter = links.enter() .append('line') .attr('class', d => `link ${d.type}`) .attr('stroke-opacity', 0); // Merge and create linksUpdate const linksUpdate = linksEnter.merge(links); // Add hover interactions to all links (existing ones will be updated) linksUpdate .on('mouseover', function(event, d) { d3.select(event.currentTarget) .attr('stroke-width', d => d.type === 'topic-link' ? Math.max(3, Math.min(5, Math.sqrt(d.count) + 2)) : 3.5) .attr('stroke-opacity', 0.9); }) .on('mouseout', function(event, d) { d3.select(event.currentTarget) .attr('stroke-width', d => d.type === 'topic-link' ? Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1)) : 2.5) .attr('stroke-opacity', 0.7); }); // Update link attributes directly (no transitions to avoid event conflicts) linksUpdate .attr('stroke', d => { if (d.type === 'center-link') return '#3498db'; return '#10b981'; }) .attr('stroke-opacity', 0.7) .attr('stroke-width', d => { if (d.type === 'topic-link') return Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1)); return 2.5; }); // Update nodes const nodes = g.selectAll('.node') .data(this.graphNodes, d => d.id); // Remove old nodes nodes.exit() .transition() .duration(300) .style('opacity', 0) .remove(); // Add new nodes const nodesEnter = nodes.enter() .append('g') .attr('class', d => `node ${d.type}`) .style('opacity', 0) .call(this.drag()); // Add circle nodesEnter.append('circle') .attr('r', d => d.type === 'center' ? 18 : 14) .attr('fill', d => { if (d.type === 'center') return '#3498db'; return '#10b981'; }) .attr('stroke', '#fff') .attr('stroke-width', 2.5); // Add text labels nodesEnter.append('text') .attr('dy', d => d.type === 'center' ? -28 : -20) .attr('text-anchor', 'middle') .attr('font-size', d => d.type === 'center' ? '16px' : '13px') .attr('fill', 'var(--text-primary)') .attr('font-weight', '600') .text(d => d.label); // Merge and update all nodes const nodesUpdate = nodesEnter.merge(nodes); // Update node visibility nodesUpdate.style('opacity', 1); // Update circles nodesUpdate.select('circle') .attr('r', d => d.type === 'center' ? 18 : 14) .attr('fill', d => { if (d.type === 'center') return '#3498db'; return '#10b981'; }); // Update text labels nodesUpdate.select('text') .text(d => d.label); // Remove any existing count badges nodesUpdate.selectAll('.count-badge').remove(); // Update simulation this.simulation.nodes(this.graphNodes); // Only add links force if there are links if (this.graphLinks.length > 0) { const linkForce = this.simulation.force('link'); if (linkForce) { linkForce.links(this.graphLinks); } else { // Create the link force if it doesn't exist this.simulation.force('link', d3.forceLink().id(d => d.id).distance(100)); this.simulation.force('link').links(this.graphLinks); } } else { // Remove link force if no links this.simulation.force('link', null); } // Update positions on tick this.simulation.on('tick', () => { if (linksUpdate && this.graphLinks.length > 0) { linksUpdate.each(function(d) { const link = d3.select(this); // Calculate direction from source to target const dx = d.target.x - d.source.x; const dy = d.target.y - d.source.y; const length = Math.sqrt(dx * dx + dy * dy); if (length > 0) { // Normalize direction vector const nx = dx / length; const ny = dy / length; // Edge radius based on target node type const targetRadius = d.target.type === 'center' ? 18 : 14; const sourceRadius = d.source.type === 'center' ? 18 : 14; // Start from edge of source node const x1 = d.source.x + nx * sourceRadius; const y1 = d.source.y + ny * sourceRadius; // End at edge of target node const x2 = d.target.x - nx * targetRadius; const y2 = d.target.y - ny * targetRadius; link.attr('x1', x1) .attr('y1', y1) .attr('x2', x2) .attr('y2', y2); } }); } nodesUpdate .attr('transform', d => `translate(${d.x},${d.y})`); }); // Use lower alpha to avoid jumping - just tweak positions gently this.simulation.alpha(0.3).restart(); } drag() { const simulation = this.simulation; function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = d.x; d.fy = d.y; } return d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended); } render() { if (!this.isInitialized) { this.initialize(); } const events = this.viewModel.get('events'); // If no events exist yet, show an empty state message if (!events || events.size === 0) { this.showEmptyState(); } else { // Remove empty state if it exists const g = this.svg.select('g'); if (!g.empty()) { g.selectAll('.empty-state').remove(); } } // Always call updateGraph to show at least the center node this.updateGraph(); } showEmptyState() { if (!this.isInitialized || !this.svg) return; const g = this.svg.select('g'); if (g.empty()) return; // Remove any existing message g.selectAll('.empty-state').remove(); // Account for the initial zoom transform (scale(1.4) translate(-200, -150)) // We need to place the text in the center of the transformed coordinate space const transformX = -200; const transformY = -150; const scale = 1.4; // Calculate centered position in transformed space const x = ((this.width / 2) - transformX) / scale; const y = ((this.height / 2) - transformY) / scale; const emptyMsg = g.append('text') .attr('class', 'empty-state') .attr('x', x) .attr('y', y) .attr('text-anchor', 'middle') .attr('font-size', '18px') .attr('fill', 'var(--text-secondary)') .attr('font-weight', '500') .text('Waiting for websocket events...'); } onPause() { if (this.simulation) { this.simulation.stop(); } } onResume() { if (this.simulation) { this.simulation.alpha(0.3).restart(); } } shouldRenderOnResume() { return false; } animateEventMessage(topic, data) { const g = this.svg.select('g'); if (g.empty()) return; const parts = data.parts || topic.split('/').filter(p => p); if (parts.length === 0) return; // Wait for the next tick to ensure nodes exist setTimeout(() => { // Build the chain: center -> first -> second -> ... -> last const chain = ['center', ...parts]; // Animate along each segment of the chain let delay = 0; for (let i = 0; i < chain.length - 1; i++) { const sourceId = chain[i]; const targetId = chain[i + 1]; const sourceNode = this.graphNodes.find(n => n.id === sourceId); const targetNode = this.graphNodes.find(n => n.id === targetId); if (!sourceNode || !targetNode) continue; setTimeout(() => { this.animateDotAlongLink(sourceNode, targetNode, g); }, delay); // Add delay between segments (staggered animation) delay += 400; // Duration (300ms) + small gap (100ms) } }, 100); } animateDotAlongLink(sourceNode, targetNode, g) { if (!sourceNode || !targetNode || !g) return; // Calculate positions const dx = targetNode.x - sourceNode.x; const dy = targetNode.y - sourceNode.y; const length = Math.sqrt(dx * dx + dy * dy); if (length === 0) return; // Normalize direction const nx = dx / length; const ny = dy / length; // Calculate node radii const sourceRadius = sourceNode.type === 'center' ? 18 : 14; const targetRadius = targetNode.type === 'center' ? 18 : 14; // Start from edge of source node const startX = sourceNode.x + nx * sourceRadius; const startY = sourceNode.y + ny * sourceRadius; // End at edge of target node const endX = targetNode.x - nx * targetRadius; const endY = targetNode.y - ny * targetRadius; // Create animation dot const animationGroup = g.append('g').attr('class', 'animation-group'); const dot = animationGroup.append('circle') .attr('r', 4) .attr('fill', '#FFD700') .attr('stroke', '#fff') .attr('stroke-width', 2) .attr('cx', startX) .attr('cy', startY); // Animate the dot along the path dot.transition() .duration(300) .ease(d3.easeLinear) .attr('cx', endX) .attr('cy', endY) .on('end', function() { // Fade out after reaching destination dot.transition() .duration(100) .style('opacity', 0) .remove(); animationGroup.remove(); }); } } window.EventComponent = EventComponent;