Skip to content

Node size auto reduces and also canvas zoom in out not reset #390

Description

@icedq-pranay

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions