ObsiViewer/src/app/graph/graph-layout.service.ts

130 lines
4.8 KiB
TypeScript

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<WorkerSettings> }
| { 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<WorkerPositionsMessage>;
end$: Observable<WorkerEndMessage>;
init(nodes: GraphNodeWithVisuals[], links: GraphLink[], settings: GraphConfig, size: { width: number; height: number }): void;
updateSettings(settings: Partial<GraphConfig> & { 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<WorkerPositionsMessage>();
const endSubj = new Subject<WorkerEndMessage>();
// Route messages outside Angular, re-enter only when emitting
this.zone.runOutsideAngular(() => {
worker.onmessage = (ev: MessageEvent<WorkerOutMsg>) => {
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<WorkerSettings> = {};
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();
}
},
};
}
}