import { Injectable, NgZone } from '@angular/core'; import { Observable, Subject, shareReplay } from 'rxjs'; import { GraphLink, GraphNodeWithVisuals } from './graph-data.types'; import { GraphConfig } from './graph-settings.types'; // Types mirrored to worker export interface WorkerNode { id: string; x?: number; y?: number; vx?: number; vy?: number; fx?: number | null; fy?: number | null; } export interface WorkerLink { source: string; target: string; } export interface WorkerSettings { width: number; height: number; centerStrength: number; repelStrength: number; // charge scale: -50 * repelStrength linkStrength: number; linkDistance: number; nodeSize: number; } export type WorkerInMsg = | { type: 'init'; nodes: WorkerNode[]; links: WorkerLink[]; settings: WorkerSettings } | { type: 'updateSettings'; settings: Partial } | { type: 'pinNode'; id: string; x: number; y: number } | { type: 'unpinNode'; id: string } | { type: 'reheat' } | { type: 'stop' }; export type WorkerPositionsMessage = { type: 'positions'; nodes: Array<{ id: string; x: number; y: number }>; alpha: number }; export type WorkerEndMessage = { type: 'end'; reason: 'converged' | 'stopped' | 'timeout' }; export type WorkerOutMsg = WorkerPositionsMessage | WorkerEndMessage; export interface GraphLayoutSession { positions$: Observable; end$: Observable; init(nodes: GraphNodeWithVisuals[], links: GraphLink[], settings: GraphConfig, size: { width: number; height: number }): void; updateSettings(settings: Partial & { width?: number; height?: number }): void; pinNode(id: string, x: number, y: number): void; unpinNode(id: string): void; reheat(): void; terminate(): void; } @Injectable({ providedIn: 'root' }) export class GraphLayoutService { constructor(private zone: NgZone) {} createSession(): GraphLayoutSession { // Create worker using bundler-friendly URL const worker = new Worker(new URL('./graph-layout.worker.ts', import.meta.url), { type: 'module' }); const positionsSubj = new Subject(); const endSubj = new Subject(); // Route messages outside Angular, re-enter only when emitting this.zone.runOutsideAngular(() => { worker.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (!msg) return; if (msg.type === 'positions') { // Avoid triggering full change detection: emit in zone only to observers this.zone.run(() => positionsSubj.next(msg)); } else if (msg.type === 'end') { this.zone.run(() => endSubj.next(msg)); } }; }); const positions$ = positionsSubj.asObservable().pipe(shareReplay({ bufferSize: 1, refCount: true })); const end$ = endSubj.asObservable().pipe(shareReplay({ bufferSize: 1, refCount: true })); const send = (payload: WorkerInMsg) => worker.postMessage(payload); const toWorkerSettings = (cfg: GraphConfig, size: { width: number; height: number }): WorkerSettings => ({ width: size.width, height: size.height, centerStrength: cfg.centerStrength, repelStrength: cfg.repelStrength, linkStrength: cfg.linkStrength, linkDistance: cfg.linkDistance, nodeSize: cfg.nodeSizeMultiplier * 5, }); return { positions$, end$, init: (nodes, links, settings, size) => { const wNodes: WorkerNode[] = nodes.map((n) => ({ id: n.id })); const wLinks: WorkerLink[] = links.map((l) => ({ source: l.source, target: l.target })); const ws = toWorkerSettings(settings, size); send({ type: 'init', nodes: wNodes, links: wLinks, settings: ws }); }, updateSettings: (patch) => { const partial: Partial = {}; if (patch.centerStrength != null) partial.centerStrength = patch.centerStrength; if (patch.repelStrength != null) partial.repelStrength = patch.repelStrength; if (patch.linkStrength != null) partial.linkStrength = patch.linkStrength; if (patch.linkDistance != null) partial.linkDistance = patch.linkDistance; if (patch.width != null) partial.width = patch.width; if (patch.height != null) partial.height = patch.height; if ((patch as any).nodeSizeMultiplier != null) partial.nodeSize = (patch as any).nodeSizeMultiplier * 5; send({ type: 'updateSettings', settings: partial }); }, pinNode: (id, x, y) => send({ type: 'pinNode', id, x, y }), unpinNode: (id) => send({ type: 'unpinNode', id }), reheat: () => send({ type: 'reheat' }), terminate: () => { try { send({ type: 'stop' }); } finally { worker.terminate(); positionsSubj.complete(); endSubj.complete(); } }, }; } }