695 lines
20 KiB
TypeScript
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();
|
|
}
|
|
}
|