import { Component, AfterViewInit, OnDestroy, effect, inject, DestroyRef, untracked, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ContextMenuModule, MenuEventArgs, MenuItemModel } from '@syncfusion/ej2-angular-navigations';
import { timer } from 'rxjs';
import ForceGraph, { LinkObject, NodeObject } from 'force-graph';
import * as d3 from 'd3';
import { AssetBrowserService } from '../asset-browser.service';
import { DagService } from './dag.service';
import { GetAssetEdges, ResponseAssetEdges, ResponseDagAsset } from './dag';
import { GlobalStorageConstants } from '../../../../../shared/constants/constant';
interface GraphData {
nodes: CustomNodeObject[];
links: CustomLinkObject[];
}
interface CustomNodeObject {
id: string;
name: string;
}
interface CustomLinkObject {
source: string;
target: string;
name: string;
nodePairId?: string;
curvature?: number;
}
@component({
selector: 'app-dag',
imports: [ContextMenuModule],
templateUrl: './dag.component.html',
styleUrl: './dag.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DagComponent implements AfterViewInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly assetBrowserService = inject(AssetBrowserService);
private readonly dagService = inject(DagService);
private graphInstance2D: ForceGraph<NodeObject, LinkObject> | null = null;
private graphData: GraphData = { nodes: [], links: [] };
private selectedLink: CustomLinkObject | null = null;
private selectedAssetNodeName: string | null = null;
private selectedAssetNode: NodeObject | null = null; // for zooming to the selected asset node.
private resizeObserver: ResizeObserver | null = null;
private container: HTMLElement | null = null;
private linkColorCache = new Map<string, string>();
private highlightNodes = new Set();
public menuItems: MenuItemModel[] = this.getDefaultMenuItems();
private static readonly GRAPH_CONTAINER_ID = '2d-graph';
private static readonly GRAPH_BACKGROUND_COLOR = '#ffffff';
private static readonly LINK_WIDTH = 0.5;
private static readonly ARROW_LENGTH = 4;
private static readonly ARROW_REL_POS = 1;
private static readonly CURVATURE_MIN_MAX = 0.5;
private static readonly SIDEBAR_WIDTH = 640;
private static readonly NODE_RADIUS = 8;
private static readonly NODE_COLLISION_RADIUS_OFFSET = 3;
private static readonly LINK_DISTANCE_MULTIPLIER = 5;
private static readonly FORCE_CHARGE_BASE = 100;
private static readonly NODE_COLOR = 'purple';
private static readonly NODE_BORDER_COLOR = 'black';
private static readonly NODE_TEXT_COLOR = 'white';
private static readonly NODE_FONT_SIZE = 14;
private static readonly NODE_FONT_FAMILY = 'Poppins, sans-serif';
constructor() {
const graphData = sessionStorage.getItem(GlobalStorageConstants.DagGraphDataKey);
if (graphData) {
const parsedData = JSON.parse(graphData) as GraphData;
// Normalize loaded data to ensure consistency (handles old format with mixed data)
this.graphData = this.normalizeGraphData(parsedData);
}
// Effect for handling asset selection
effect(() => {
const asset = this.assetBrowserService.selectAssetSignal();
if (asset?.name) {
const assetName = asset.name;
this.selectedAssetNodeName = assetName;
const nodeExists = this.graphData.nodes.some(node => node.id === assetName);
this.addNodeIfNotExists(assetName);
if (!nodeExists && this.graphData.nodes.length > 0) {
this.getDagAssetData(assetName, '', false);
}
}
});
// Separate effect for handling canvas clearing
effect(() => {
if (this.assetBrowserService.clearCanvasSignal()) {
this.clearCanvas();
untracked(() => {
sessionStorage.removeItem(GlobalStorageConstants.DagGraphDataKey);
this.assetBrowserService.selectAssetSignal.set(null);
this.assetBrowserService.clearCanvasSignal.set(false);
});
}
});
// ResizeObserver handles all resize events, no need for separate effect
}
private clearCanvas(): void {
this.selectedAssetNodeName = null;
this.selectedAssetNode = null;
this.selectedLink = null;
this.graphData = { nodes: [], links: [] };
this.linkColorCache.clear();
this.updateGraphData();
if (this.graphInstance2D) {
if (!this.container) {
return;
}
this.graphInstance2D = new ForceGraph(this.container);
this.configureGraphInstance(this.container);
}
}
ngAfterViewInit(): void {
this.draw2DGraph();
this.setupResizeObserver();
}
private draw2DGraph(): void {
this.container = document.getElementById(DagComponent.GRAPH_CONTAINER_ID);
if (!this.container) {
return;
}
this.graphInstance2D = new ForceGraph(this.container);
this.configureGraphInstance(this.container);
this.graphInstance2D.graphData(this.graphData);
}
private configureGraphInstance(container: HTMLElement): void {
if (!this.graphInstance2D) {
return;
}
// configure canvas settings
this.graphInstance2D.backgroundColor(DagComponent.GRAPH_BACKGROUND_COLOR);
this.graphInstance2D.width(container.clientWidth);
this.graphInstance2D.height(container.clientHeight);
// this.graphInstance2D.onBackgroundRightClick(() => {
// this.menuItems = [
// { text: 'Clear Canvas', iconCss: 'e-icons e-canvas-icn' }
// ]
// this.changeDetectorRef.markForCheck();
// });
this.graphInstance2D.warmupTicks(100);
this.graphInstance2D.cooldownTicks(1) //to prevent infinite simulation.
this.graphInstance2D.d3AlphaDecay(0.02); //to prevent infinite simulation.
// this.graphInstance2D.d3Force('charge', d3.forceManyBody().strength(-Math.max(DagComponent.FORCE_CHARGE_BASE, this.graphData.nodes.length / 2)));
// this.graphInstance2D.d3Force('collide', d3.forceCollide().radius(DagComponent.NODE_RADIUS + DagComponent.NODE_COLLISION_RADIUS_OFFSET));
// this.graphInstance2D.d3Force('link', d3.forceLink().distance(DagComponent.NODE_RADIUS * DagComponent.LINK_DISTANCE_MULTIPLIER));
// this.graphInstance2D.d3Force("center", null);
this.graphInstance2D.d3Force('collide', d3.forceCollide(2));
this.graphInstance2D.d3Force("charge", d3.forceManyBody().strength(-200));
this.graphInstance2D.d3Force("center", null);
this.graphInstance2D.d3Force("x", d3.forceX());
this.graphInstance2D.d3Force("y", d3.forceY());
this.graphInstance2D.autoPauseRedraw(false) // keep redrawing after engine has stopped
// Configure node settings
this.graphInstance2D.nodeRelSize(DagComponent.NODE_RADIUS);
this.graphInstance2D.nodeVal(1);
this.graphInstance2D.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
const radius = DagComponent.NODE_RADIUS;
const customNode = node as CustomNodeObject;
const label = customNode.name || customNode.id || '';
const fontSize = DagComponent.NODE_FONT_SIZE / globalScale;
const x = node.x ?? 0;
const y = node.y ?? 0;
// Draw filled circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI, false);
ctx.fillStyle = DagComponent.NODE_COLOR;
ctx.fill();
// Add border
ctx.lineWidth = 0.5;
ctx.strokeStyle = this.highlightNodes.has(node.id as string) ? 'orange' : DagComponent.NODE_BORDER_COLOR;
ctx.stroke();
// Set text style
ctx.font = `${fontSize}px ${DagComponent.NODE_FONT_FAMILY}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = DagComponent.NODE_TEXT_COLOR;
// Measure text width and truncate with ellipsis if needed
const maxTextWidth = (radius - 2) * 1.8;
const textWidth = ctx.measureText(label).width;
let displayText = label;
if (textWidth > maxTextWidth) {
const ellipsis = '...';
const ellipsisWidth = ctx.measureText(ellipsis).width;
const availableWidth = maxTextWidth - ellipsisWidth;
// Binary search for optimal truncation point
let low = 0;
let high = label.length;
let bestFit = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const sub = label.substring(0, mid);
const subWidth = ctx.measureText(sub).width;
if (subWidth <= availableWidth) {
bestFit = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
displayText = bestFit > 0 ? label.substring(0, bestFit) + ellipsis : ellipsis;
}
// Draw text inside circle
ctx.fillText(displayText, node.x ?? 0, node.y ?? 0);
});
this.graphInstance2D.onNodeClick((node: NodeObject) => {
if (node) {
this.selectedAssetNode = node;
}
});
this.graphInstance2D.onNodeHover((node: NodeObject | null, previousNode: NodeObject | null) => {
if (node) {
this.highlightNodes.add(node.id as string);
}
if (previousNode) {
this.highlightNodes.delete(previousNode.id as string);
}
});
this.graphInstance2D.onNodeRightClick((node: NodeObject) => {
this.selectedAssetNode = node;
const nodeId = node.id as string;
this.selectedAssetNodeName = nodeId;
if (nodeId) {
this.getAssetLinksNameByAssetName(nodeId);
}
});
// Configure link settings
// Use linkColor with a function to ensure colors are applied
this.graphInstance2D.linkColor((link: LinkObject<NodeObject>) => {
const customLink = link as CustomLinkObject;
return this.getLinkColor(customLink.name);
});
this.graphInstance2D.linkWidth(DagComponent.LINK_WIDTH);
this.graphInstance2D.linkDirectionalArrowLength(DagComponent.ARROW_LENGTH);
this.graphInstance2D.linkDirectionalArrowRelPos(DagComponent.ARROW_REL_POS);
this.graphInstance2D.linkDirectionalArrowColor((link: LinkObject<NodeObject>) => {
const customLink = link as CustomLinkObject;
// Use the same color as the link for the arrow
return this.getLinkColor(customLink.name);
});
this.graphInstance2D.linkCurvature((link: LinkObject<NodeObject>) => {
const customLink = link as CustomLinkObject;
return customLink.curvature ?? 0;
});
this.graphInstance2D.linkCanvasObjectMode(() => 'after');
this.graphInstance2D.linkCanvasObject((link: LinkObject<NodeObject>, ctx: CanvasRenderingContext2D, globalScale: number) => {
const customLink = link as CustomLinkObject;
const start: any = link.source;
const end: any = link.target;
if (typeof start !== 'object' || typeof end !== 'object') return;
// === Midpoint for label ===
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
// === Direction vector and rotation ===
const relLink = { x: end.x - start.x, y: end.y - start.y };
const linkLength = Math.sqrt(relLink.x ** 2 + relLink.y ** 2);
let textAngle = Math.atan2(relLink.y, relLink.x);
if (textAngle > Math.PI / 2) textAngle -= Math.PI;
if (textAngle < -Math.PI / 2) textAngle += Math.PI;
// === Label ===
const label = customLink.name || `${start.id} → ${end.id}`;
ctx.font = `${Math.max(2.5 / globalScale, 2)}px Sans-Serif`;
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, 4].map(n => n + 2.5); // padding
// === Draw label at center ===
ctx.save();
ctx.translate(midX, midY);
ctx.rotate(textAngle);
// Background (semi-transparent white)
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillRect(-bckgDimensions[0] / 2, -bckgDimensions[1] / 2, bckgDimensions[0], bckgDimensions[1]);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'darkslategray';
ctx.fillText(label, 0, 0);
ctx.restore();
});
this.graphInstance2D.onLinkRightClick((link: LinkObject<NodeObject>) => {
if (link) {
this.selectedLink = link as CustomLinkObject;
this.menuItems = [
{ text: 'Hide Link', iconCss: 'e-icons hide-icn' },
{ text: 'Delete Link', iconCss: 'e-icons e-delete-icn' }
];
}
});
}
private updateGraphData(): void {
if (this.graphInstance2D) {
const normalizedData = this.normalizeGraphData(this.graphData);
sessionStorage.setItem(GlobalStorageConstants.DagGraphDataKey, JSON.stringify(normalizedData));
this.graphInstance2D.graphData(this.graphData);
// Delay zoom and center operations to allow graph to settle
if (this.selectedAssetNode) {
timer(500).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.graphInstance2D?.centerAt(this.selectedAssetNode?.x ?? 0, this.selectedAssetNode?.y ?? 0, 1000);
this.graphInstance2D?.zoom(4, 2000);
});
}
}
}
private normalizeGraphData(graphData: GraphData): GraphData {
// Normalize nodes: keep only id and name, remove force-graph internal properties
const normalizedNodes: CustomNodeObject[] = graphData.nodes.map(node => {
const nodeId = typeof node.id === 'string' ? node.id : String(node.id ?? '');
const nodeName = (typeof node === 'object' && node !== null && 'name' in node && typeof node.name === 'string')
? node.name
: nodeId;
return {
id: nodeId,
name: nodeName
};
});
// Normalize links: convert source/target from node objects to strings
const normalizedLinks: CustomLinkObject[] = graphData.links.map(link => {
const sourceName = this.getNodeName(link.source as string | NodeObject);
const targetName = this.getNodeName(link.target as string | NodeObject);
return {
source: sourceName,
target: targetName,
name: link.name ?? '',
nodePairId: link.nodePairId,
curvature: link.curvature
};
});
return {
nodes: normalizedNodes,
links: normalizedLinks
};
}
public selectMenu(event: MenuEventArgs): void {
const menuText = event.item.text ?? '';
const assetName = this.selectedAssetNodeName ?? '';
switch (menuText) {
case 'Show All':
this.getDagAssetData(assetName, '', true);
break;
case 'Hide Link':
this.handleHideLink();
break;
case 'Hide':
case 'Delete':
case 'Edit':
// Placeholder for future implementation
break;
// case 'Clear Canvas':
// this.clearCanvas();
// break;
default:
this.getDagAssetData(assetName, menuText, true);
break;
}
}
private handleHideLink(): void {
if (!this.selectedLink) {
return;
}
const linkToRemove = this.graphData.links.find(
link => link.nodePairId === this.selectedLink?.nodePairId
);
if (linkToRemove) {
this.removeLink(linkToRemove);
}
this.selectedLink = null;
}
private getDagAssetData(assetName: string, relationType: string, isShowAll: boolean = false): void {
this.dagService
.getDagAssetData({ assetName, relationType })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data: ResponseDagAsset[]) => {
this.generateGraphData(data, isShowAll);
},
error: (error: unknown) => {
if (error instanceof Error) {
}
}
});
}
private addNodeIfNotExists(nodeId: string): void {
// Use Set for O(1) lookup instead of O(n) array search
const nodeIds = new Set(this.graphData.nodes.map(node => node.id));
if (!nodeIds.has(nodeId)) {
this.graphData.nodes.push({ id: nodeId, name: nodeId });
if (this.graphInstance2D) {
this.updateGraphData();
}
}
}
private generateGraphData(data: ResponseDagAsset[], isShowAll: boolean = false): void {
const existingNodes = new Set(this.graphData.nodes.map(node => node.id));
const existingLinks = this.buildExistingLinksSet();
const nodesToAdd = new Set();
const linksToAdd = new Map<string, CustomLinkObject>();
if (isShowAll) {
this.processShowAllMode(data, existingNodes, existingLinks, nodesToAdd, linksToAdd);
} else {
this.processNormalMode(data, existingNodes, existingLinks, linksToAdd);
}
this.addNodesAndLinks(nodesToAdd, linksToAdd);
if (linksToAdd.size > 0 || this.graphData.links.length > 0) {
this.computeCurvedLinksCurvature();
}
if (nodesToAdd.size > 0 || linksToAdd.size > 0) {
this.updateGraphData();
}
}
private getNodeName(node: string | NodeObject): string {
if (typeof node === 'string') {
return node;
}
if (node?.id && typeof node.id === 'string') {
return node.id;
}
return String(node ?? '');
}
private getLinkColor(relationshipType: string | undefined): string {
if (!relationshipType) {
return '#999';
}
// Use cache to avoid recalculating colors
if (this.linkColorCache.has(relationshipType)) {
return this.linkColorCache.get(relationshipType)!;
}
// Simple hash function to generate consistent colors based on relationship type
let hash = 0;
for (let i = 0; i < relationshipType.length; i++) {
hash = relationshipType.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash % 360);
const color = `hsl(${hue}, 70%, 50%)`;
this.linkColorCache.set(relationshipType, color);
return color;
}
private buildExistingLinksSet(): Set {
const existingLinks = new Set();
this.graphData.links.forEach(link => {
const sourceName = this.getNodeName(link.source as string | NodeObject);
const targetName = this.getNodeName(link.target as string | NodeObject);
// Include relationship type in the key to allow multiple links between same nodes with different types
existingLinks.add(${sourceName}-${targetName}-${link.name});
existingLinks.add(${targetName}-${sourceName}-${link.name});
});
return existingLinks;
}
private hasLinkInMap(
linksToAdd: Map<string, CustomLinkObject>,
fromAsset: string,
toAsset: string,
relationshipType: string
): boolean {
const forwardKey = ${fromAsset}-${toAsset}-${relationshipType};
const reverseKey = ${toAsset}-${fromAsset}-${relationshipType};
return linksToAdd.has(forwardKey) || linksToAdd.has(reverseKey);
}
private processShowAllMode(
data: ResponseDagAsset[],
existingNodes: Set,
existingLinks: Set,
nodesToAdd: Set,
linksToAdd: Map<string, CustomLinkObject>
): void {
data.forEach(item => {
if (!existingNodes.has(item.fromAsset)) {
nodesToAdd.add({ id: item.fromAsset, name: item.fromAsset });
}
if (!existingNodes.has(item.toAsset)) {
nodesToAdd.add({ id: item.toAsset, name: item.toAsset });
}
const linkKey = `${item.fromAsset}-${item.toAsset}-${item.relationshipType}`;
if (!existingLinks.has(linkKey) && !this.hasLinkInMap(linksToAdd, item.fromAsset, item.toAsset, item.relationshipType)) {
const link = this.createLink(item.fromAsset, item.toAsset, item.relationshipType);
linksToAdd.set(linkKey, link);
}
});
}
private processNormalMode(
data: ResponseDagAsset[],
existingNodes: Set,
existingLinks: Set,
linksToAdd: Map<string, CustomLinkObject>
): void {
data.forEach(item => {
const linkKey = ${item.fromAsset}-${item.toAsset}-${item.relationshipType};
const linkExists = existingLinks.has(linkKey);
const bothNodesExist = existingNodes.has(item.fromAsset) && existingNodes.has(item.toAsset);
if (!linkExists && bothNodesExist && !this.hasLinkInMap(linksToAdd, item.fromAsset, item.toAsset, item.relationshipType)) {
const link = this.createLink(item.fromAsset, item.toAsset, item.relationshipType);
linksToAdd.set(linkKey, link);
}
});
}
private createLink(fromAsset: string, toAsset: string, relationshipType: string): CustomLinkObject {
const nodePairId = fromAsset <= toAsset
? ${fromAsset}_${toAsset}
: ${toAsset}_${fromAsset};
return {
source: fromAsset,
target: toAsset,
name: relationshipType,
nodePairId
};
}
private addNodesAndLinks(
nodesToAdd: Set,
linksToAdd: Map<string, CustomLinkObject>
): void {
if (nodesToAdd.size > 0) {
this.graphData.nodes.push(...Array.from(nodesToAdd));
}
if (linksToAdd.size > 0) {
this.graphData.links.push(...Array.from(linksToAdd.values()));
}
}
private computeCurvedLinksCurvature(): void {
const selfLoopLinks: Record<string, CustomLinkObject[]> = {};
const sameNodesLinks: Record<string, CustomLinkObject[]> = {};
const curvatureMinMax = DagComponent.CURVATURE_MIN_MAX;
this.groupLinksByNodePairs(selfLoopLinks, sameNodesLinks);
this.computeSelfLoopCurvatures(selfLoopLinks, curvatureMinMax);
this.computeSameNodesCurvatures(sameNodesLinks, curvatureMinMax);
this.setDefaultCurvatures();
}
private groupLinksByNodePairs(
selfLoopLinks: Record<string, CustomLinkObject[]>,
sameNodesLinks: Record<string, CustomLinkObject[]>
): void {
this.graphData.links.forEach(link => {
const sourceName = this.getNodeName(link.source as string | NodeObject);
const targetName = this.getNodeName(link.target as string | NodeObject);
if (!link.nodePairId) {
link.nodePairId = sourceName <= targetName
? `${sourceName}_${targetName}`
: `${targetName}_${sourceName}`;
}
const map = sourceName === targetName ? selfLoopLinks : sameNodesLinks;
if (!map[link.nodePairId]) {
map[link.nodePairId] = [];
}
map[link.nodePairId].push(link);
});
}
private computeSelfLoopCurvatures(
selfLoopLinks: Record<string, CustomLinkObject[]>,
curvatureMinMax: number
): void {
Object.keys(selfLoopLinks).forEach(id => {
const links = selfLoopLinks[id];
if (links.length === 0) {
return;
}
const lastIndex = links.length - 1;
if (lastIndex === 0) {
links[0].curvature = 1;
} else {
links[lastIndex].curvature = 1;
const delta = (1 - curvatureMinMax) / lastIndex;
for (let i = 0; i < lastIndex; i++) {
links[i].curvature = curvatureMinMax + i * delta;
}
}
});
}
private computeSameNodesCurvatures(
sameNodesLinks: Record<string, CustomLinkObject[]>,
curvatureMinMax: number
): void {
Object.keys(sameNodesLinks)
.filter(nodePairId => sameNodesLinks[nodePairId].length > 1)
.forEach(nodePairId => {
const links = sameNodesLinks[nodePairId];
const lastIndex = links.length - 1;
const lastLink = links[lastIndex];
if (!lastLink) {
return;
}
lastLink.curvature = curvatureMinMax;
const delta = (2 * curvatureMinMax) / lastIndex;
for (let i = 0; i < lastIndex; i++) {
const currentLink = links[i];
if (!currentLink) {
continue;
}
currentLink.curvature = -curvatureMinMax + i * delta;
const lastSourceName = this.getNodeName(lastLink.source as string | NodeObject);
const currentSourceName = this.getNodeName(currentLink.source as string | NodeObject);
if (lastSourceName !== currentSourceName) {
currentLink.curvature *= -1;
}
}
});
}
private setDefaultCurvatures(): void {
this.graphData.links.forEach(link => {
if (link.curvature === undefined) {
link.curvature = 0;
}
});
}
private removeLink(link: CustomLinkObject): void {
const linkIndex = this.graphData.links.indexOf(link);
if (linkIndex > -1) {
this.graphData.links.splice(linkIndex, 1);
this.updateGraphData();
}
}
private getAssetLinksNameByAssetName(assetName: string): void {
const getAssetEdges: GetAssetEdges = { assetName };
this.dagService
.getAssetLinksNameBYAssetName(getAssetEdges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (responseAssetEdges: ResponseAssetEdges) => {
this.menuItems = this.getDefaultMenuItems();
if (responseAssetEdges.edges?.length) {
const linkItems: MenuItemModel[] = responseAssetEdges.edges.map(
(edge: string) => ({ text: edge, iconCss: 'e-icons e-link' })
);
this.menuItems.push(
{ separator: true },
{ text: 'Link', iconCss: 'e-icons e-link', items: linkItems }
);
}
this.changeDetectorRef.markForCheck();
},
error: (error: unknown) => {
// Error handling: log to error service in production
// For now, silently fail to prevent UI disruption
if (error instanceof Error) {
// Could integrate with error logging service here
}
}
});
}
private getDefaultMenuItems(): MenuItemModel[] {
return [
{ text: 'Show All', iconCss: 'e-icons show-all' },
{ text: 'Hide', iconCss: 'e-icons hide-icn' },
{ text: 'Delete', iconCss: 'e-icons e-delete-icn' },
{ text: 'Edit', iconCss: 'e-icons e-edit-icn' }
];
}
private setupResizeObserver(): void {
if (!this.container) {
return;
}
this.resizeObserver = new ResizeObserver(() => {
if (this.graphInstance2D && this.container) {
this.updateCanvasWidthFromContainer();
}
});
this.resizeObserver.observe(this.container);
}
private updateCanvasWidthFromContainer(): void {
if (!this.graphInstance2D || !this.container) {
return;
}
requestAnimationFrame(() => {
if (!this.graphInstance2D || !this.container) {
return;
}
const newWidth = this.container.clientWidth;
const currentWidth = this.graphInstance2D.width() ?? 0;
// Update if there's a meaningful difference (more than 1px)
if (Math.abs(newWidth - currentWidth) > 1) {
this.graphInstance2D.width(newWidth);
// Force re-render to reflect width change
this.graphInstance2D.d3ReheatSimulation();
}
});
}
ngOnDestroy(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this.linkColorCache.clear();
this.container = null;
if (this.graphInstance2D) {
this.selectedAssetNodeName = null;
this.selectedAssetNode = null;
this.selectedLink = null;
this.graphInstance2D._destructor();
this.graphInstance2D = null;
}
}
}
I have shared code above
Issues: 1) when there are multiple nodes node size auto reduces
2) when I zoom out or in for canvas it will maintain canvas state after I clear canvas
import { Component, AfterViewInit, OnDestroy, effect, inject, DestroyRef, untracked, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ContextMenuModule, MenuEventArgs, MenuItemModel } from '@syncfusion/ej2-angular-navigations';
import { timer } from 'rxjs';
import ForceGraph, { LinkObject, NodeObject } from 'force-graph';
import * as d3 from 'd3';
import { AssetBrowserService } from '../asset-browser.service';
import { DagService } from './dag.service';
import { GetAssetEdges, ResponseAssetEdges, ResponseDagAsset } from './dag';
import { GlobalStorageConstants } from '../../../../../shared/constants/constant';
interface GraphData {
nodes: CustomNodeObject[];
links: CustomLinkObject[];
}
interface CustomNodeObject {
id: string;
name: string;
}
interface CustomLinkObject {
source: string;
target: string;
name: string;
nodePairId?: string;
curvature?: number;
}
@component({
selector: 'app-dag',
imports: [ContextMenuModule],
templateUrl: './dag.component.html',
styleUrl: './dag.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DagComponent implements AfterViewInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly assetBrowserService = inject(AssetBrowserService);
private readonly dagService = inject(DagService);
private graphInstance2D: ForceGraph<NodeObject, LinkObject> | null = null;
private graphData: GraphData = { nodes: [], links: [] };
private selectedLink: CustomLinkObject | null = null;
private selectedAssetNodeName: string | null = null;
private selectedAssetNode: NodeObject | null = null; // for zooming to the selected asset node.
private resizeObserver: ResizeObserver | null = null;
private container: HTMLElement | null = null;
private linkColorCache = new Map<string, string>();
private highlightNodes = new Set();
public menuItems: MenuItemModel[] = this.getDefaultMenuItems();
private static readonly GRAPH_CONTAINER_ID = '2d-graph';
private static readonly GRAPH_BACKGROUND_COLOR = '#ffffff';
private static readonly LINK_WIDTH = 0.5;
private static readonly ARROW_LENGTH = 4;
private static readonly ARROW_REL_POS = 1;
private static readonly CURVATURE_MIN_MAX = 0.5;
private static readonly SIDEBAR_WIDTH = 640;
private static readonly NODE_RADIUS = 8;
private static readonly NODE_COLLISION_RADIUS_OFFSET = 3;
private static readonly LINK_DISTANCE_MULTIPLIER = 5;
private static readonly FORCE_CHARGE_BASE = 100;
private static readonly NODE_COLOR = 'purple';
private static readonly NODE_BORDER_COLOR = 'black';
private static readonly NODE_TEXT_COLOR = 'white';
private static readonly NODE_FONT_SIZE = 14;
private static readonly NODE_FONT_FAMILY = 'Poppins, sans-serif';
constructor() {
const graphData = sessionStorage.getItem(GlobalStorageConstants.DagGraphDataKey);
if (graphData) {
const parsedData = JSON.parse(graphData) as GraphData;
// Normalize loaded data to ensure consistency (handles old format with mixed data)
this.graphData = this.normalizeGraphData(parsedData);
}
// Effect for handling asset selection
effect(() => {
const asset = this.assetBrowserService.selectAssetSignal();
if (asset?.name) {
const assetName = asset.name;
this.selectedAssetNodeName = assetName;
const nodeExists = this.graphData.nodes.some(node => node.id === assetName);
this.addNodeIfNotExists(assetName);
if (!nodeExists && this.graphData.nodes.length > 0) {
this.getDagAssetData(assetName, '', false);
}
}
});
}
private clearCanvas(): void {
this.selectedAssetNodeName = null;
this.selectedAssetNode = null;
this.selectedLink = null;
this.graphData = { nodes: [], links: [] };
this.linkColorCache.clear();
this.updateGraphData();
if (this.graphInstance2D) {
if (!this.container) {
return;
}
this.graphInstance2D = new ForceGraph(this.container);
this.configureGraphInstance(this.container);
}
}
ngAfterViewInit(): void {
this.draw2DGraph();
this.setupResizeObserver();
}
private draw2DGraph(): void {
this.container = document.getElementById(DagComponent.GRAPH_CONTAINER_ID);
if (!this.container) {
return;
}
}
private configureGraphInstance(container: HTMLElement): void {
if (!this.graphInstance2D) {
return;
}
}
private updateGraphData(): void {
if (this.graphInstance2D) {
const normalizedData = this.normalizeGraphData(this.graphData);
sessionStorage.setItem(GlobalStorageConstants.DagGraphDataKey, JSON.stringify(normalizedData));
this.graphInstance2D.graphData(this.graphData);
}
private normalizeGraphData(graphData: GraphData): GraphData {
// Normalize nodes: keep only id and name, remove force-graph internal properties
const normalizedNodes: CustomNodeObject[] = graphData.nodes.map(node => {
const nodeId = typeof node.id === 'string' ? node.id : String(node.id ?? '');
const nodeName = (typeof node === 'object' && node !== null && 'name' in node && typeof node.name === 'string')
? node.name
: nodeId;
}
public selectMenu(event: MenuEventArgs): void {
const menuText = event.item.text ?? '';
const assetName = this.selectedAssetNodeName ?? '';
}
private handleHideLink(): void {
if (!this.selectedLink) {
return;
}
}
private getDagAssetData(assetName: string, relationType: string, isShowAll: boolean = false): void {
this.dagService
.getDagAssetData({ assetName, relationType })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data: ResponseDagAsset[]) => {
this.generateGraphData(data, isShowAll);
},
error: (error: unknown) => {
if (error instanceof Error) {
}
}
});
}
private addNodeIfNotExists(nodeId: string): void {
// Use Set for O(1) lookup instead of O(n) array search
const nodeIds = new Set(this.graphData.nodes.map(node => node.id));
if (!nodeIds.has(nodeId)) {
this.graphData.nodes.push({ id: nodeId, name: nodeId });
if (this.graphInstance2D) {
this.updateGraphData();
}
}
}
private generateGraphData(data: ResponseDagAsset[], isShowAll: boolean = false): void {
const existingNodes = new Set(this.graphData.nodes.map(node => node.id));
const existingLinks = this.buildExistingLinksSet();
const nodesToAdd = new Set();
const linksToAdd = new Map<string, CustomLinkObject>();
}
private getNodeName(node: string | NodeObject): string {
if (typeof node === 'string') {
return node;
}
if (node?.id && typeof node.id === 'string') {
return node.id;
}
return String(node ?? '');
}
private getLinkColor(relationshipType: string | undefined): string {
if (!relationshipType) {
return '#999';
}
}
private buildExistingLinksSet(): Set {
const existingLinks = new Set();
this.graphData.links.forEach(link => {
const sourceName = this.getNodeName(link.source as string | NodeObject);
const targetName = this.getNodeName(link.target as string | NodeObject);
// Include relationship type in the key to allow multiple links between same nodes with different types
existingLinks.add(
${sourceName}-${targetName}-${link.name});existingLinks.add(
${targetName}-${sourceName}-${link.name});});
return existingLinks;
}
private hasLinkInMap(
linksToAdd: Map<string, CustomLinkObject>,
fromAsset: string,
toAsset: string,
relationshipType: string
): boolean {
const forwardKey =
${fromAsset}-${toAsset}-${relationshipType};const reverseKey =
${toAsset}-${fromAsset}-${relationshipType};return linksToAdd.has(forwardKey) || linksToAdd.has(reverseKey);
}
private processShowAllMode(
data: ResponseDagAsset[],
existingNodes: Set,
existingLinks: Set,
nodesToAdd: Set,
linksToAdd: Map<string, CustomLinkObject>
): void {
data.forEach(item => {
if (!existingNodes.has(item.fromAsset)) {
nodesToAdd.add({ id: item.fromAsset, name: item.fromAsset });
}
if (!existingNodes.has(item.toAsset)) {
nodesToAdd.add({ id: item.toAsset, name: item.toAsset });
}
}
private processNormalMode(
data: ResponseDagAsset[],
existingNodes: Set,
existingLinks: Set,
linksToAdd: Map<string, CustomLinkObject>
): void {
data.forEach(item => {
const linkKey =
${item.fromAsset}-${item.toAsset}-${item.relationshipType};const linkExists = existingLinks.has(linkKey);
const bothNodesExist = existingNodes.has(item.fromAsset) && existingNodes.has(item.toAsset);
}
private createLink(fromAsset: string, toAsset: string, relationshipType: string): CustomLinkObject {
const nodePairId = fromAsset <= toAsset
?
${fromAsset}_${toAsset}:
${toAsset}_${fromAsset};}
private addNodesAndLinks(
nodesToAdd: Set,
linksToAdd: Map<string, CustomLinkObject>
): void {
if (nodesToAdd.size > 0) {
this.graphData.nodes.push(...Array.from(nodesToAdd));
}
if (linksToAdd.size > 0) {
this.graphData.links.push(...Array.from(linksToAdd.values()));
}
}
private computeCurvedLinksCurvature(): void {
const selfLoopLinks: Record<string, CustomLinkObject[]> = {};
const sameNodesLinks: Record<string, CustomLinkObject[]> = {};
const curvatureMinMax = DagComponent.CURVATURE_MIN_MAX;
}
private groupLinksByNodePairs(
selfLoopLinks: Record<string, CustomLinkObject[]>,
sameNodesLinks: Record<string, CustomLinkObject[]>
): void {
this.graphData.links.forEach(link => {
const sourceName = this.getNodeName(link.source as string | NodeObject);
const targetName = this.getNodeName(link.target as string | NodeObject);
}
private computeSelfLoopCurvatures(
selfLoopLinks: Record<string, CustomLinkObject[]>,
curvatureMinMax: number
): void {
Object.keys(selfLoopLinks).forEach(id => {
const links = selfLoopLinks[id];
if (links.length === 0) {
return;
}
}
private computeSameNodesCurvatures(
sameNodesLinks: Record<string, CustomLinkObject[]>,
curvatureMinMax: number
): void {
Object.keys(sameNodesLinks)
.filter(nodePairId => sameNodesLinks[nodePairId].length > 1)
.forEach(nodePairId => {
const links = sameNodesLinks[nodePairId];
const lastIndex = links.length - 1;
const lastLink = links[lastIndex];
}
private setDefaultCurvatures(): void {
this.graphData.links.forEach(link => {
if (link.curvature === undefined) {
link.curvature = 0;
}
});
}
private removeLink(link: CustomLinkObject): void {
const linkIndex = this.graphData.links.indexOf(link);
if (linkIndex > -1) {
this.graphData.links.splice(linkIndex, 1);
this.updateGraphData();
}
}
private getAssetLinksNameByAssetName(assetName: string): void {
const getAssetEdges: GetAssetEdges = { assetName };
this.dagService
.getAssetLinksNameBYAssetName(getAssetEdges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (responseAssetEdges: ResponseAssetEdges) => {
this.menuItems = this.getDefaultMenuItems();
}
private getDefaultMenuItems(): MenuItemModel[] {
return [
{ text: 'Show All', iconCss: 'e-icons show-all' },
{ text: 'Hide', iconCss: 'e-icons hide-icn' },
{ text: 'Delete', iconCss: 'e-icons e-delete-icn' },
{ text: 'Edit', iconCss: 'e-icons e-edit-icn' }
];
}
private setupResizeObserver(): void {
if (!this.container) {
return;
}
}
private updateCanvasWidthFromContainer(): void {
if (!this.graphInstance2D || !this.container) {
return;
}
}
ngOnDestroy(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
}
I have shared code above
Issues: 1) when there are multiple nodes node size auto reduces
2) when I zoom out or in for canvas it will maintain canvas state after I clear canvas