diff --git a/src/app/component/dependency-graph/dependency-graph.component.html b/src/app/component/dependency-graph/dependency-graph.component.html index a5a51e10..88e1f241 100644 --- a/src/app/component/dependency-graph/dependency-graph.component.html +++ b/src/app/component/dependency-graph/dependency-graph.component.html @@ -1 +1 @@ - + diff --git a/src/app/component/dependency-graph/dependency-graph.component.ts b/src/app/component/dependency-graph/dependency-graph.component.ts index 6753eece..eccaec7e 100644 --- a/src/app/component/dependency-graph/dependency-graph.component.ts +++ b/src/app/component/dependency-graph/dependency-graph.component.ts @@ -6,6 +6,8 @@ import { DataStore } from 'src/app/model/data-store'; export interface graphNodes { id: string; + relativeLevel: number; + relativeCount: number; } export interface graphLinks { @@ -24,9 +26,10 @@ export interface graph { styleUrls: ['./dependency-graph.component.css'], }) export class DependencyGraphComponent implements OnInit { - SIZE_OF_NODE: number = 10; COLOR_OF_LINK: string = 'black'; - COLOR_OF_NODE: string = '#55bc55'; + COLOR_OF_NODE: string = '#66bb6a'; + COLOR_OF_PREDECESSOR: string = '#deeedeff'; + COLOR_OF_SUCCESSOR: string = '#fdfdfdff'; BORDER_COLOR_OF_NODE: string = 'black'; simulation: any; dataStore: Partial = {}; @@ -39,7 +42,7 @@ export class DependencyGraphComponent implements OnInit { ngOnInit(): void { this.loader.load().then((dataStore: DataStore) => { - this.dataStore = this.dataStore; + this.dataStore = dataStore; if (!dataStore.activityStore) { throw Error('No activity store loaded'); } @@ -57,8 +60,9 @@ export class DependencyGraphComponent implements OnInit { populateGraphWithActivitiesCurrentActivityDependsOn(activity: Activity): void { this.addNode(activity.name); if (activity.dependsOn) { + let i: number = 1; for (const prececcor of activity.dependsOn) { - this.addNode(prececcor); + this.addNode(prececcor, -1, i++); this.graphData['links'].push({ source: prececcor, target: activity.name, @@ -69,9 +73,10 @@ export class DependencyGraphComponent implements OnInit { populateGraphWithActivitiesThatDependsOnCurrentActivity(currentActivity: Activity) { const all: Activity[] = this.dataStore.activityStore?.getAllActivities?.() ?? []; + let i: number = 1; for (const activity of all) { if (activity.dependsOn?.includes(currentActivity.name)) { - this.addNode(activity.name); + this.addNode(activity.name, 1, i++); this.graphData['links'].push({ source: currentActivity.name, target: activity.name, @@ -80,40 +85,58 @@ export class DependencyGraphComponent implements OnInit { } } - addNode(activityName: string) { + addNode(activityName: string, relativeLevel: number = 0, relativeCount: number = 0): void { if (!this.visited.has(activityName)) { - this.graphData['nodes'].push({ id: activityName }); + this.graphData['nodes'].push({ id: activityName, relativeLevel, relativeCount }); this.visited.add(activityName); } } generateGraph(activityName: string): void { - let svg = d3.select('svg'), - width = +svg.attr('width'), - height = +svg.attr('height'); + let svg = d3.select('svg'); + // Now that rectWidth is set on each node, set up the simulation this.simulation = d3 .forceSimulation() .force( 'link', - d3.forceLink().id(function (d: any) { - return d.id; - }) + d3 + .forceLink() + .id(function (d: any) { + return d.id; + }) + .strength(0.1) + ) + .force( + 'x', + d3 + .forceX((d: any) => { + let col: number = 7; + return d.relativeLevel * Math.ceil(d.relativeCount / col) * 300; + }) + .strength(5) + ) + // .force('y', d3.forceY((d: any) => { + // return d.relativeLevel * 30; + // }).strength(10)) + .force('charge', d3.forceManyBody().strength(-80)) + .force( + 'collide', + d3.forceCollide((d: any) => 30) ) - .force('charge', d3.forceManyBody().strength(-12000)) - .force('center', d3.forceCenter(width / 2, height / 2)); + .force('center', d3.forceCenter(0, 0)); svg .append('defs') .append('marker') .attr('id', 'arrowhead') .attr('viewBox', '-0 -5 10 10') - .attr('refX', 18) + .attr('refX', 0) .attr('refY', 0) .attr('orient', 'auto') .attr('markerWidth', 13) .attr('markerHeight', 13) - .attr('xoverflow', 'visible') + .attr('overflow', 'visible') .append('svg:path') .attr('d', 'M 0,-5 L 10 ,0 L 0,5') .attr('fill', this.COLOR_OF_LINK) @@ -139,28 +162,104 @@ export class DependencyGraphComponent implements OnInit { .append('g'); /* eslint-enable */ - var defaultNodeColor = this.COLOR_OF_NODE; - node - .append('circle') - .attr('r', 10) - .attr('fill', function (d) { - if (d.id == activityName) return 'yellow'; - else return defaultNodeColor; - }); + const rectHeight = 30; + const rectRx = 10; + const rectRy = 10; + const padding = 20; + // Append text first so we can measure it node .append('text') - .attr('dy', '.35em') + .attr('dy', '0.35em') .attr('text-anchor', 'middle') .text(function (d) { return d.id; }); - this.simulation.nodes(this.graphData['nodes']).on('tick', ticked); + // Now for each node, measure the text and insert a rect behind it + const self = this; + node.each(function (this: SVGGElement, d: any) { + const textElem = d3.select(this).select('text').node() as SVGTextElement; + let textWidth = 60; // fallback default + if (textElem && textElem.getBBox) { + textWidth = textElem.getBBox().width; + } + const rectWidth = textWidth + padding; + d.rectWidth = rectWidth; // Store for collision force + // Insert rect before text + d3.select(this) + .insert('rect', 'text') + .attr('x', -rectWidth / 2) + .attr('y', -rectHeight / 2) + .attr('width', rectWidth) + .attr('height', rectHeight) + .attr('rx', rectRx) + .attr('ry', rectRy) + .attr('fill', (d: any) => { + if (d.relativeLevel == 0) return self.COLOR_OF_NODE; + return d.relativeLevel < 0 ? self.COLOR_OF_PREDECESSOR : self.COLOR_OF_SUCCESSOR; + }) + .attr('stroke', self.BORDER_COLOR_OF_NODE) + .attr('stroke-width', 1.5); + }); + + this.simulation.nodes(this.graphData['nodes']).on('tick', () => { + self.rectCollide(this.graphData['nodes']); + ticked(); + }); this.simulation.force('link').links(this.graphData['links']); function ticked() { + // Improved rectangle edge intersection for arrowhead placement + function rectEdgeIntersection( + sx: number, + sy: number, + tx: number, + ty: number, + rectWidth: number, + rectHeight: number, + offset: number = 0 + ) { + // Rectangle centered at (tx, ty) + const dx = tx - sx; + const dy = ty - sy; + const w = rectWidth / 2; + const h = rectHeight / 2; + // Parametric line: (sx, sy) + t*(dx, dy), t in [0,1] + // Find smallest t in (0,1] where line crosses rectangle edge + let tMin = 1; + // Left/right sides + if (dx !== 0) { + let t1 = (w - (sx - tx)) / dx; + let y1 = sy + t1 * dy; + if (t1 > 0 && Math.abs(y1 - ty) <= h) tMin = Math.min(tMin, t1); + let t2 = (-w - (sx - tx)) / dx; + let y2 = sy + t2 * dy; + if (t2 > 0 && Math.abs(y2 - ty) <= h) tMin = Math.min(tMin, t2); + } + // Top/bottom sides + if (dy !== 0) { + let t3 = (h - (sy - ty)) / dy; + let x3 = sx + t3 * dx; + if (t3 > 0 && Math.abs(x3 - tx) <= w) tMin = Math.min(tMin, t3); + let t4 = (-h - (sy - ty)) / dy; + let x4 = sx + t4 * dx; + if (t4 > 0 && Math.abs(x4 - tx) <= w) tMin = Math.min(tMin, t4); + } + // Clamp tMin to [0,1] + tMin = Math.max(0, Math.min(1, tMin)); + // Move intersection back by 'offset' pixels along the direction from target to source + let px = sx + dx * tMin; + let py = sy + dy * tMin; + if (offset > 0 && (dx !== 0 || dy !== 0)) { + const len = Math.sqrt(dx * dx + dy * dy); + px -= (dx / len) * offset; + py -= (dy / len) * offset; + } + return { x: px, y: py }; + } + link .attr('x1', function (d: any) { return d.source.x; @@ -169,9 +268,34 @@ export class DependencyGraphComponent implements OnInit { return d.source.y; }) .attr('x2', function (d: any) { + // If target has rectWidth, adjust arrow to edge minus offset + if (d.target.rectWidth) { + const pt = rectEdgeIntersection( + d.source.x, + d.source.y, + d.target.x, + d.target.y, + d.target.rectWidth, + 30, + 10 // rectHeight, offset + ); + return pt.x; + } return d.target.x; }) .attr('y2', function (d: any) { + if (d.target.rectWidth) { + const pt = rectEdgeIntersection( + d.source.x, + d.source.y, + d.target.x, + d.target.y, + d.target.rectWidth, + 30, + 10 + ); + return pt.y; + } return d.target.y; }); @@ -180,4 +304,56 @@ export class DependencyGraphComponent implements OnInit { }); } } + + /** + * Custom rectangular collision force for D3 simulation. + * Pushes nodes apart if their rectangles (boxes) overlap. + * Assumes each node has .x, .y, and .rectWidth properties. + * Uses a fixed rectHeight of 30 (half = 15). + * @param nodes Array of node objects + */ + rectCollide(nodes: any[]) { + // Loop through all pairs of nodes + let node, + nx1, + nx2, + ny1, + ny2, + other, + ox1, + ox2, + oy1, + oy2, + i, + n = nodes.length; + for (i = 0; i < n; ++i) { + node = nodes[i]; + // Calculate bounding box for node + nx1 = node.x - node.rectWidth / 2; + nx2 = node.x + node.rectWidth / 2; + ny1 = node.y - 15; // rectHeight / 2 + ny2 = node.y + 15; + for (let j = i + 1; j < n; ++j) { + other = nodes[j]; + // Calculate bounding box for other node + ox1 = other.x - other.rectWidth / 2; + ox2 = other.x + other.rectWidth / 2; + oy1 = other.y - 15; + oy2 = other.y + 15; + // Check for overlap between rectangles + if (nx1 < ox2 && nx2 > ox1 && ny1 < oy2 && ny2 > oy1) { + // Overlap detected, push nodes apart along the direction between them + let dx = node.x - other.x || Math.random() - 0.5; + let dy = node.y - other.y || Math.random() - 0.5; + let l = Math.sqrt(dx * dx + dy * dy); + let moveX = dx / l || 1; + let moveY = dy / l || 1; + node.x += moveX; + node.y += moveY; + other.x -= moveX; + other.y -= moveY; + } + } + } + } }