/** * Canvas-based Graph Renderer * High-performance graph visualization using HTML5 Canvas and d3-force */ import { Component, ChangeDetectionStrategy, input, output, signal, computed, effect, viewChild, ElementRef, afterNextRender, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { GraphNodeWithVisuals, GraphLink, SimulationNode } from './graph-data.types'; import { GraphConfig } from './graph-settings.types'; import { SpatialIndex, drawArrow } from './graph.utils'; import { GraphLayoutService, type GraphLayoutSession } from './graph-layout.service'; import { DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; interface TooltipData { node: GraphNodeWithVisuals; x: number; y: number; } @Component({ selector: 'app-graph-canvas', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@if (tooltip()) {
{{ tooltip()!.node.title }}
{{ tooltip()!.node.path }}
@if (tooltip()!.node.tags.length > 0) {
{{ tooltip()!.node.tags.join(', ') }}
}
}
Nodes: {{ nodes().length }}
Links: {{ links().length }}
`, host: { '[attr.aria-busy]': 'ariaBusy() ? "true" : "false"' }, styles: [` :host { display: block; width: 100%; height: 100%; } `] }) export class GraphCanvasComponent implements OnDestroy { // Inputs nodes = input.required(); links = input.required(); settings = input.required(); centerNodeId = input(null); // Outputs nodeClicked = output(); nodeHovered = output(); // Canvas reference canvas = viewChild.required>('canvas'); // State tooltip = signal(null); private transform = signal({ x: 0, y: 0, k: 1 }); private simulationNodes = signal([]); private simulationLinks = signal([]); // Simulation private layout = inject(GraphLayoutService); private session: GraphLayoutSession | null = null; private animationFrameId: number | null = null; private isAnimating = false; // Interaction state private isDragging = false; private isPanning = false; private dragStart = { x: 0, y: 0 }; private draggedNode: SimulationNode | null = null; private hoveredNode: SimulationNode | null = null; private selectedNodeId: string | null = null; private spatialIndex: SpatialIndex | null = null; // Canvas dimensions private width = 800; private height = 600; private ctx: CanvasRenderingContext2D | null = null; private readonly destroyRef = inject(DestroyRef); private boundResizeHandler: (() => void) | null = null; private ariaBusy = signal(false); // Track last update to avoid unnecessary reinitializations private lastNodeCount = 0; private lastLinkCount = 0; // Neighbors cache for highlighting private neighborMap = computed(() => { const map = new Map>(); this.links().forEach(link => { if (!map.has(link.source)) map.set(link.source, new Set()); if (!map.has(link.target)) map.set(link.target, new Set()); map.get(link.source)!.add(link.target); map.get(link.target)!.add(link.source); }); return map; }); constructor() { afterNextRender(() => { this.initCanvas(); this.initWorker(); }); // React to data changes with debounce to prevent cascade updates let updateTimer: ReturnType | null = null; effect(() => { const nodes = this.nodes(); const links = this.links(); // Debounce updates to avoid freezing on rapid changes if (updateTimer) clearTimeout(updateTimer); updateTimer = setTimeout(() => { this.updateWorkerData(nodes, links); }, 100); }); // React to settings changes effect(() => { const settings = this.settings(); this.updateWorkerSettings(settings); this.scheduleRedraw(); }); // React to center node changes effect(() => { const centerId = this.centerNodeId(); if (centerId) { this.focusOnNode(centerId); } }); } ngOnDestroy(): void { if (this.session) { this.session.terminate(); this.session = null; } if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); } if (this.boundResizeHandler) { window.removeEventListener('resize', this.boundResizeHandler); this.boundResizeHandler = null; } } /** * Initialize canvas and rendering context */ private initCanvas(): void { const canvasEl = this.canvas().nativeElement; this.ctx = canvasEl.getContext('2d'); if (!this.ctx) { console.error('Failed to get canvas context'); return; } // Set canvas size to match display size this.resizeCanvas(); // Handle window resize this.boundResizeHandler = () => { this.resizeCanvas(); if (this.session) { this.session.updateSettings({ width: this.width, height: this.height }); } }; window.addEventListener('resize', this.boundResizeHandler); } /** * Resize canvas to match container */ private resizeCanvas(): void { const canvasEl = this.canvas().nativeElement; const rect = canvasEl.getBoundingClientRect(); this.width = rect.width; this.height = rect.height; // Set actual canvas size (accounting for device pixel ratio) const dpr = window.devicePixelRatio || 1; canvasEl.width = this.width * dpr; canvasEl.height = this.height * dpr; if (this.ctx) { this.ctx.scale(dpr, dpr); } this.scheduleRedraw(); } /** * Initialize d3-force simulation */ private initWorker(): void { // Create a new layout session this.session = this.layout.createSession(); // Subscribe to worker position updates let lastIndexBuild = 0; this.session.positions$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(msg => { const map = new Map(this.simulationNodes().map(n => [n.id, n] as const)); for (const p of msg.nodes) { const node = map.get(p.id); if (node) { node.x = p.x; node.y = p.y; } } // Trigger redraw this.scheduleRedraw(); // Rebuild spatial index at most every 500ms to reduce main thread load const now = performance.now(); if (now - lastIndexBuild > 500) { this.spatialIndex = new SpatialIndex(this.simulationNodes()); lastIndexBuild = now; } }); // Stop animation when simulation ends this.session.end$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.isAnimating = false; this.ariaBusy.set(false); }); } /** * Update simulation with new data */ private updateWorkerData(nodes: GraphNodeWithVisuals[], links: GraphLink[]): void { // Skip update if data hasn't substantially changed (same counts) if (nodes.length === this.lastNodeCount && links.length === this.lastLinkCount && this.session) { console.log(`[GraphCanvas] Skipping update - same data size (${nodes.length} nodes, ${links.length} links)`); return; } console.log(`[GraphCanvas] Updating worker: ${nodes.length} nodes, ${links.length} links (prev: ${this.lastNodeCount}/${this.lastLinkCount})`); this.lastNodeCount = nodes.length; this.lastLinkCount = links.length; // Create local nodes with initial jitter for better convergence visuals const simNodes: SimulationNode[] = nodes.map(node => ({ ...node, x: this.width / 2 + (Math.random() - 0.5) * 100, y: this.height / 2 + (Math.random() - 0.5) * 100 })); this.simulationNodes.set(simNodes); this.simulationLinks.set(links); this.spatialIndex = new SpatialIndex(simNodes); // Initialize or re-init the worker with new data if (!this.session) { this.initWorker(); } if (this.session) { this.session.init(simNodes, links, this.settings(), { width: this.width, height: this.height }); } // Kick an animation frame to draw initial state this.isAnimating = true; this.ariaBusy.set(true); this.startAnimationLoop(); } /** * Update force parameters */ private updateWorkerSettings(settings: GraphConfig): void { if (!this.session) return; this.session.updateSettings({ centerStrength: settings.centerStrength, repelStrength: settings.repelStrength, linkStrength: settings.linkStrength, linkDistance: settings.linkDistance, nodeSizeMultiplier: settings.nodeSizeMultiplier as any, } as any); } /** * Simulation tick handler */ private onSimulationTick(): void { // retained for compatibility; worker drives updates this.scheduleRedraw(); } /** * Start animation loop */ private startAnimationLoop(): void { if (this.animationFrameId) return; const animate = () => { // Draw at least one frame; subsequent frames depend on isAnimating flag if (this.isAnimating) { this.draw(); this.animationFrameId = requestAnimationFrame(animate); } else { this.animationFrameId = null; this.draw(); // Final draw } }; this.animationFrameId = requestAnimationFrame(animate); } /** * Schedule a redraw */ private scheduleRedraw(): void { if (!this.isAnimating && !this.animationFrameId) { requestAnimationFrame(() => this.draw()); } } /** * Main draw function */ private draw(): void { if (!this.ctx) return; const ctx = this.ctx; const t = this.transform(); const settings = this.settings(); const nodes = this.simulationNodes(); const links = this.simulationLinks(); // Clear canvas ctx.clearRect(0, 0, this.width, this.height); // Save context ctx.save(); // Apply transform ctx.translate(t.x, t.y); ctx.scale(t.k, t.k); // Draw links this.drawLinks(ctx, links, settings); // Draw nodes this.drawNodes(ctx, nodes, settings); // Draw labels this.drawLabels(ctx, nodes, settings); // Restore context ctx.restore(); } /** * Draw links */ private drawLinks(ctx: CanvasRenderingContext2D, links: GraphLink[], settings: GraphConfig): void { const nodeSize = settings.nodeSizeMultiplier * 5; const linkThickness = settings.lineSizeMultiplier * 1; links.forEach(link => { const source = this.simulationNodes().find(n => n.id === link.source); const target = this.simulationNodes().find(n => n.id === link.target); if (!source || !target || source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return; // Dim link if node is selected and this link is not connected let opacity = 0.6; if (this.selectedNodeId) { const isConnected = source.id === this.selectedNodeId || target.id === this.selectedNodeId; opacity = isConnected ? 0.8 : 0.2; } ctx.strokeStyle = `rgba(156, 163, 175, ${opacity})`; ctx.lineWidth = linkThickness; ctx.beginPath(); ctx.moveTo(source.x, source.y); ctx.lineTo(target.x, target.y); ctx.stroke(); // Draw arrow if enabled if (settings.showArrow && link.directed !== false) { ctx.fillStyle = `rgba(156, 163, 175, ${opacity})`; drawArrow(ctx, source.x, source.y, target.x, target.y, nodeSize, 6); } }); } /** * Draw nodes */ private drawNodes(ctx: CanvasRenderingContext2D, nodes: SimulationNode[], settings: GraphConfig): void { const nodeSize = settings.nodeSizeMultiplier * 5; nodes.forEach(node => { if (node.x === undefined || node.y === undefined) return; // Calculate opacity based on selection let opacity = 1; if (this.selectedNodeId) { if (node.id === this.selectedNodeId) { opacity = 1; } else { const neighbors = this.neighborMap().get(this.selectedNodeId); opacity = neighbors && neighbors.has(node.id) ? 1 : 0.3; } } // Draw node circle ctx.fillStyle = node.color; ctx.globalAlpha = opacity; ctx.beginPath(); ctx.arc(node.x, node.y, nodeSize, 0, 2 * Math.PI); ctx.fill(); // Highlight selected or hovered if (node.id === this.selectedNodeId || node === this.hoveredNode) { ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 2; ctx.globalAlpha = 1; ctx.stroke(); } ctx.globalAlpha = 1; }); } /** * Draw labels */ private drawLabels(ctx: CanvasRenderingContext2D, nodes: SimulationNode[], settings: GraphConfig): void { const nodeSize = settings.nodeSizeMultiplier * 5; const threshold = settings.textFadeMultiplier; const zoom = this.transform().k; // Calculate if labels should be visible based on zoom and threshold const showLabels = Math.log10(zoom) >= threshold / 3; if (!showLabels) return; ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; nodes.forEach(node => { if (node.x === undefined || node.y === undefined) return; // Calculate opacity based on selection let opacity = 1; if (this.selectedNodeId) { if (node.id === this.selectedNodeId) { opacity = 1; } else { const neighbors = this.neighborMap().get(this.selectedNodeId); opacity = neighbors && neighbors.has(node.id) ? 1 : 0.3; } } ctx.fillStyle = `rgba(31, 41, 55, ${opacity})`; ctx.globalAlpha = opacity; ctx.fillText(node.title, node.x, node.y - nodeSize - 4); ctx.globalAlpha = 1; }); } /** * Mouse event handlers */ onMouseDown(event: MouseEvent): void { const rect = this.canvas().nativeElement.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; // Convert to graph coordinates const t = this.transform(); const graphX = (x - t.x) / t.k; const graphY = (y - t.y) / t.k; // Check if clicking on a node const nodeSize = this.settings().nodeSizeMultiplier * 5; const clickedNode = this.spatialIndex?.findNodeAt(graphX, graphY, nodeSize * 2); if (clickedNode) { this.draggedNode = clickedNode; this.isDragging = true; if (clickedNode.x !== undefined && clickedNode.y !== undefined) { this.session?.pinNode(clickedNode.id, clickedNode.x, clickedNode.y); this.session?.reheat(); } } else { this.isPanning = true; this.dragStart = { x: event.clientX, y: event.clientY }; } } onMouseMove(event: MouseEvent): void { const rect = this.canvas().nativeElement.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; if (this.isDragging && this.draggedNode) { // Drag node const t = this.transform(); const graphX = (x - t.x) / t.k; const graphY = (y - t.y) / t.k; this.session?.pinNode(this.draggedNode.id, graphX, graphY); this.isAnimating = true; this.startAnimationLoop(); } else if (this.isPanning) { // Pan canvas const dx = event.clientX - this.dragStart.x; const dy = event.clientY - this.dragStart.y; const t = this.transform(); this.transform.set({ ...t, x: t.x + dx, y: t.y + dy }); this.dragStart = { x: event.clientX, y: event.clientY }; this.scheduleRedraw(); } else { // Check for hover const t = this.transform(); const graphX = (x - t.x) / t.k; const graphY = (y - t.y) / t.k; const nodeSize = this.settings().nodeSizeMultiplier * 5; const hoveredNode = this.spatialIndex?.findNodeAt(graphX, graphY, nodeSize * 2); if (hoveredNode !== this.hoveredNode) { this.hoveredNode = hoveredNode || null; if (hoveredNode) { this.tooltip.set({ node: hoveredNode, x, y }); this.nodeHovered.emit(hoveredNode); } else { this.tooltip.set(null); this.nodeHovered.emit(null); } this.scheduleRedraw(); } else if (hoveredNode) { // Update tooltip position this.tooltip.set({ node: hoveredNode, x, y }); } } } onMouseUp(event: MouseEvent): void { if (this.isDragging && this.draggedNode) { this.session?.unpinNode(this.draggedNode.id); this.draggedNode = null; } this.isDragging = false; this.isPanning = false; } onMouseLeave(event: MouseEvent): void { this.onMouseUp(event); this.hoveredNode = null; this.tooltip.set(null); this.nodeHovered.emit(null); this.scheduleRedraw(); } onClick(event: MouseEvent): void { if (this.isDragging || this.isPanning) return; const rect = this.canvas().nativeElement.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const t = this.transform(); const graphX = (x - t.x) / t.k; const graphY = (y - t.y) / t.k; const nodeSize = this.settings().nodeSizeMultiplier * 5; const clickedNode = this.spatialIndex?.findNodeAt(graphX, graphY, nodeSize * 2); if (clickedNode) { this.selectedNodeId = this.selectedNodeId === clickedNode.id ? null : clickedNode.id; this.nodeClicked.emit(clickedNode); this.scheduleRedraw(); } else { this.selectedNodeId = null; this.scheduleRedraw(); } } onWheel(event: WheelEvent): void { event.preventDefault(); const rect = this.canvas().nativeElement.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const delta = -event.deltaY / 1000; const t = this.transform(); const newK = Math.max(0.2, Math.min(5, t.k * (1 + delta))); // Zoom towards cursor const factor = newK / t.k; this.transform.set({ x: x - (x - t.x) * factor, y: y - (y - t.y) * factor, k: newK }); this.scheduleRedraw(); } /** * Public API: Focus on a specific node */ focusOnNode(nodeId: string): void { const node = this.simulationNodes().find(n => n.id === nodeId); if (!node || node.x === undefined || node.y === undefined) return; // Center on node with smooth animation const targetX = this.width / 2 - node.x * this.transform().k; const targetY = this.height / 2 - node.y * this.transform().k; this.transform.set({ ...this.transform(), x: targetX, y: targetY }); this.selectedNodeId = nodeId; this.scheduleRedraw(); } /** * Public API: Restart animation */ animate(): void { this.session?.reheat(); this.isAnimating = true; this.startAnimationLoop(); } }