130 lines
4.8 KiB
TypeScript
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();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
}
|