ObsiViewer/src/app/graph/graph-canvas.component.ts

695 lines
20 KiB
TypeScript

/**
* 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: `
<div class="relative w-full h-full bg-gray-50 dark:bg-gray-900">
<canvas
#canvas
class="w-full h-full cursor-grab active:cursor-grabbing"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp($event)"
(mouseleave)="onMouseLeave($event)"
(wheel)="onWheel($event)"
(click)="onClick($event)">
</canvas>
<!-- Tooltip -->
@if (tooltip()) {
<div
class="absolute pointer-events-none bg-gray-800 dark:bg-gray-700 text-white text-xs px-3 py-2 rounded-lg shadow-lg z-50"
[style.left.px]="tooltip()!.x + 10"
[style.top.px]="tooltip()!.y - 10">
<div class="font-semibold">{{ tooltip()!.node.title }}</div>
<div class="text-gray-300 text-xs">{{ tooltip()!.node.path }}</div>
@if (tooltip()!.node.tags.length > 0) {
<div class="text-gray-400 text-xs mt-1">
{{ tooltip()!.node.tags.join(', ') }}
</div>
}
</div>
}
<!-- Info overlay -->
<div class="absolute top-4 left-4 bg-white dark:bg-gray-800 px-3 py-2 rounded-lg shadow-md text-xs text-gray-700 dark:text-gray-300 pointer-events-none">
<div><strong>Nodes:</strong> {{ nodes().length }}</div>
<div><strong>Links:</strong> {{ links().length }}</div>
</div>
</div>
`,
host: {
'[attr.aria-busy]': 'ariaBusy() ? "true" : "false"'
},
styles: [`
:host {
display: block;
width: 100%;
height: 100%;
}
`]
})
export class GraphCanvasComponent implements OnDestroy {
// Inputs
nodes = input.required<GraphNodeWithVisuals[]>();
links = input.required<GraphLink[]>();
settings = input.required<GraphConfig>();
centerNodeId = input<string | null>(null);
// Outputs
nodeClicked = output<GraphNodeWithVisuals>();
nodeHovered = output<GraphNodeWithVisuals | null>();
// Canvas reference
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
// State
tooltip = signal<TooltipData | null>(null);
private transform = signal({ x: 0, y: 0, k: 1 });
private simulationNodes = signal<SimulationNode[]>([]);
private simulationLinks = signal<GraphLink[]>([]);
// 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<boolean>(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<string, Set<string>>();
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<typeof setTimeout> | 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();
}
}