Skip to content

Commit 91c17ba

Browse files
authored
Merge pull request #1 from vbakke/v4-feat/dependancy-graph
Improved dependency graph
2 parents 3548fd2 + 9934322 commit 91c17ba

File tree

2 files changed

+204
-28
lines changed

2 files changed

+204
-28
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<svg width="1920" height="1280"></svg>
1+
<svg width="100%" height="100%" viewBox="-750 -250 1500 500"></svg>

src/app/component/dependency-graph/dependency-graph.component.ts

Lines changed: 203 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { DataStore } from 'src/app/model/data-store';
66

77
export interface graphNodes {
88
id: string;
9+
relativeLevel: number;
10+
relativeCount: number;
911
}
1012

1113
export interface graphLinks {
@@ -24,9 +26,10 @@ export interface graph {
2426
styleUrls: ['./dependency-graph.component.css'],
2527
})
2628
export class DependencyGraphComponent implements OnInit {
27-
SIZE_OF_NODE: number = 10;
2829
COLOR_OF_LINK: string = 'black';
29-
COLOR_OF_NODE: string = '#55bc55';
30+
COLOR_OF_NODE: string = '#66bb6a';
31+
COLOR_OF_PREDECESSOR: string = '#deeedeff';
32+
COLOR_OF_SUCCESSOR: string = '#fdfdfdff';
3033
BORDER_COLOR_OF_NODE: string = 'black';
3134
simulation: any;
3235
dataStore: Partial<DataStore> = {};
@@ -39,7 +42,7 @@ export class DependencyGraphComponent implements OnInit {
3942

4043
ngOnInit(): void {
4144
this.loader.load().then((dataStore: DataStore) => {
42-
this.dataStore = this.dataStore;
45+
this.dataStore = dataStore;
4346
if (!dataStore.activityStore) {
4447
throw Error('No activity store loaded');
4548
}
@@ -57,8 +60,9 @@ export class DependencyGraphComponent implements OnInit {
5760
populateGraphWithActivitiesCurrentActivityDependsOn(activity: Activity): void {
5861
this.addNode(activity.name);
5962
if (activity.dependsOn) {
63+
let i: number = 1;
6064
for (const prececcor of activity.dependsOn) {
61-
this.addNode(prececcor);
65+
this.addNode(prececcor, -1, i++);
6266
this.graphData['links'].push({
6367
source: prececcor,
6468
target: activity.name,
@@ -69,9 +73,10 @@ export class DependencyGraphComponent implements OnInit {
6973

7074
populateGraphWithActivitiesThatDependsOnCurrentActivity(currentActivity: Activity) {
7175
const all: Activity[] = this.dataStore.activityStore?.getAllActivities?.() ?? [];
76+
let i: number = 1;
7277
for (const activity of all) {
7378
if (activity.dependsOn?.includes(currentActivity.name)) {
74-
this.addNode(activity.name);
79+
this.addNode(activity.name, 1, i++);
7580
this.graphData['links'].push({
7681
source: currentActivity.name,
7782
target: activity.name,
@@ -80,40 +85,58 @@ export class DependencyGraphComponent implements OnInit {
8085
}
8186
}
8287

83-
addNode(activityName: string) {
88+
addNode(activityName: string, relativeLevel: number = 0, relativeCount: number = 0): void {
8489
if (!this.visited.has(activityName)) {
85-
this.graphData['nodes'].push({ id: activityName });
90+
this.graphData['nodes'].push({ id: activityName, relativeLevel, relativeCount });
8691
this.visited.add(activityName);
8792
}
8893
}
8994

9095
generateGraph(activityName: string): void {
91-
let svg = d3.select('svg'),
92-
width = +svg.attr('width'),
93-
height = +svg.attr('height');
96+
let svg = d3.select('svg');
9497

98+
// Now that rectWidth is set on each node, set up the simulation
9599
this.simulation = d3
96100
.forceSimulation()
97101
.force(
98102
'link',
99-
d3.forceLink().id(function (d: any) {
100-
return d.id;
101-
})
103+
d3
104+
.forceLink()
105+
.id(function (d: any) {
106+
return d.id;
107+
})
108+
.strength(0.1)
109+
)
110+
.force(
111+
'x',
112+
d3
113+
.forceX((d: any) => {
114+
let col: number = 7;
115+
return d.relativeLevel * Math.ceil(d.relativeCount / col) * 300;
116+
})
117+
.strength(5)
118+
)
119+
// .force('y', d3.forceY((d: any) => {
120+
// return d.relativeLevel * 30;
121+
// }).strength(10))
122+
.force('charge', d3.forceManyBody().strength(-80))
123+
.force(
124+
'collide',
125+
d3.forceCollide((d: any) => 30)
102126
)
103-
.force('charge', d3.forceManyBody().strength(-12000))
104-
.force('center', d3.forceCenter(width / 2, height / 2));
127+
.force('center', d3.forceCenter(0, 0));
105128

106129
svg
107130
.append('defs')
108131
.append('marker')
109132
.attr('id', 'arrowhead')
110133
.attr('viewBox', '-0 -5 10 10')
111-
.attr('refX', 18)
134+
.attr('refX', 0)
112135
.attr('refY', 0)
113136
.attr('orient', 'auto')
114137
.attr('markerWidth', 13)
115138
.attr('markerHeight', 13)
116-
.attr('xoverflow', 'visible')
139+
.attr('overflow', 'visible')
117140
.append('svg:path')
118141
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
119142
.attr('fill', this.COLOR_OF_LINK)
@@ -139,28 +162,104 @@ export class DependencyGraphComponent implements OnInit {
139162
.append('g');
140163
/* eslint-enable */
141164

142-
var defaultNodeColor = this.COLOR_OF_NODE;
143-
node
144-
.append('circle')
145-
.attr('r', 10)
146-
.attr('fill', function (d) {
147-
if (d.id == activityName) return 'yellow';
148-
else return defaultNodeColor;
149-
});
165+
const rectHeight = 30;
166+
const rectRx = 10;
167+
const rectRy = 10;
168+
const padding = 20;
150169

170+
// Append text first so we can measure it
151171
node
152172
.append('text')
153-
.attr('dy', '.35em')
173+
.attr('dy', '0.35em')
154174
.attr('text-anchor', 'middle')
155175
.text(function (d) {
156176
return d.id;
157177
});
158178

159-
this.simulation.nodes(this.graphData['nodes']).on('tick', ticked);
179+
// Now for each node, measure the text and insert a rect behind it
180+
const self = this;
181+
node.each(function (this: SVGGElement, d: any) {
182+
const textElem = d3.select(this).select('text').node() as SVGTextElement;
183+
let textWidth = 60; // fallback default
184+
if (textElem && textElem.getBBox) {
185+
textWidth = textElem.getBBox().width;
186+
}
187+
const rectWidth = textWidth + padding;
188+
d.rectWidth = rectWidth; // Store for collision force
189+
// Insert rect before text
190+
d3.select(this)
191+
.insert('rect', 'text')
192+
.attr('x', -rectWidth / 2)
193+
.attr('y', -rectHeight / 2)
194+
.attr('width', rectWidth)
195+
.attr('height', rectHeight)
196+
.attr('rx', rectRx)
197+
.attr('ry', rectRy)
198+
.attr('fill', (d: any) => {
199+
if (d.relativeLevel == 0) return self.COLOR_OF_NODE;
200+
return d.relativeLevel < 0 ? self.COLOR_OF_PREDECESSOR : self.COLOR_OF_SUCCESSOR;
201+
})
202+
.attr('stroke', self.BORDER_COLOR_OF_NODE)
203+
.attr('stroke-width', 1.5);
204+
});
205+
206+
this.simulation.nodes(this.graphData['nodes']).on('tick', () => {
207+
self.rectCollide(this.graphData['nodes']);
208+
ticked();
209+
});
160210

161211
this.simulation.force('link').links(this.graphData['links']);
162212

163213
function ticked() {
214+
// Improved rectangle edge intersection for arrowhead placement
215+
function rectEdgeIntersection(
216+
sx: number,
217+
sy: number,
218+
tx: number,
219+
ty: number,
220+
rectWidth: number,
221+
rectHeight: number,
222+
offset: number = 0
223+
) {
224+
// Rectangle centered at (tx, ty)
225+
const dx = tx - sx;
226+
const dy = ty - sy;
227+
const w = rectWidth / 2;
228+
const h = rectHeight / 2;
229+
// Parametric line: (sx, sy) + t*(dx, dy), t in [0,1]
230+
// Find smallest t in (0,1] where line crosses rectangle edge
231+
let tMin = 1;
232+
// Left/right sides
233+
if (dx !== 0) {
234+
let t1 = (w - (sx - tx)) / dx;
235+
let y1 = sy + t1 * dy;
236+
if (t1 > 0 && Math.abs(y1 - ty) <= h) tMin = Math.min(tMin, t1);
237+
let t2 = (-w - (sx - tx)) / dx;
238+
let y2 = sy + t2 * dy;
239+
if (t2 > 0 && Math.abs(y2 - ty) <= h) tMin = Math.min(tMin, t2);
240+
}
241+
// Top/bottom sides
242+
if (dy !== 0) {
243+
let t3 = (h - (sy - ty)) / dy;
244+
let x3 = sx + t3 * dx;
245+
if (t3 > 0 && Math.abs(x3 - tx) <= w) tMin = Math.min(tMin, t3);
246+
let t4 = (-h - (sy - ty)) / dy;
247+
let x4 = sx + t4 * dx;
248+
if (t4 > 0 && Math.abs(x4 - tx) <= w) tMin = Math.min(tMin, t4);
249+
}
250+
// Clamp tMin to [0,1]
251+
tMin = Math.max(0, Math.min(1, tMin));
252+
// Move intersection back by 'offset' pixels along the direction from target to source
253+
let px = sx + dx * tMin;
254+
let py = sy + dy * tMin;
255+
if (offset > 0 && (dx !== 0 || dy !== 0)) {
256+
const len = Math.sqrt(dx * dx + dy * dy);
257+
px -= (dx / len) * offset;
258+
py -= (dy / len) * offset;
259+
}
260+
return { x: px, y: py };
261+
}
262+
164263
link
165264
.attr('x1', function (d: any) {
166265
return d.source.x;
@@ -169,9 +268,34 @@ export class DependencyGraphComponent implements OnInit {
169268
return d.source.y;
170269
})
171270
.attr('x2', function (d: any) {
271+
// If target has rectWidth, adjust arrow to edge minus offset
272+
if (d.target.rectWidth) {
273+
const pt = rectEdgeIntersection(
274+
d.source.x,
275+
d.source.y,
276+
d.target.x,
277+
d.target.y,
278+
d.target.rectWidth,
279+
30,
280+
10 // rectHeight, offset
281+
);
282+
return pt.x;
283+
}
172284
return d.target.x;
173285
})
174286
.attr('y2', function (d: any) {
287+
if (d.target.rectWidth) {
288+
const pt = rectEdgeIntersection(
289+
d.source.x,
290+
d.source.y,
291+
d.target.x,
292+
d.target.y,
293+
d.target.rectWidth,
294+
30,
295+
10
296+
);
297+
return pt.y;
298+
}
175299
return d.target.y;
176300
});
177301

@@ -180,4 +304,56 @@ export class DependencyGraphComponent implements OnInit {
180304
});
181305
}
182306
}
307+
308+
/**
309+
* Custom rectangular collision force for D3 simulation.
310+
* Pushes nodes apart if their rectangles (boxes) overlap.
311+
* Assumes each node has .x, .y, and .rectWidth properties.
312+
* Uses a fixed rectHeight of 30 (half = 15).
313+
* @param nodes Array of node objects
314+
*/
315+
rectCollide(nodes: any[]) {
316+
// Loop through all pairs of nodes
317+
let node,
318+
nx1,
319+
nx2,
320+
ny1,
321+
ny2,
322+
other,
323+
ox1,
324+
ox2,
325+
oy1,
326+
oy2,
327+
i,
328+
n = nodes.length;
329+
for (i = 0; i < n; ++i) {
330+
node = nodes[i];
331+
// Calculate bounding box for node
332+
nx1 = node.x - node.rectWidth / 2;
333+
nx2 = node.x + node.rectWidth / 2;
334+
ny1 = node.y - 15; // rectHeight / 2
335+
ny2 = node.y + 15;
336+
for (let j = i + 1; j < n; ++j) {
337+
other = nodes[j];
338+
// Calculate bounding box for other node
339+
ox1 = other.x - other.rectWidth / 2;
340+
ox2 = other.x + other.rectWidth / 2;
341+
oy1 = other.y - 15;
342+
oy2 = other.y + 15;
343+
// Check for overlap between rectangles
344+
if (nx1 < ox2 && nx2 > ox1 && ny1 < oy2 && ny2 > oy1) {
345+
// Overlap detected, push nodes apart along the direction between them
346+
let dx = node.x - other.x || Math.random() - 0.5;
347+
let dy = node.y - other.y || Math.random() - 0.5;
348+
let l = Math.sqrt(dx * dx + dy * dy);
349+
let moveX = dx / l || 1;
350+
let moveY = dy / l || 1;
351+
node.x += moveX;
352+
node.y += moveY;
353+
other.x -= moveX;
354+
other.y -= moveY;
355+
}
356+
}
357+
}
358+
}
183359
}

0 commit comments

Comments
 (0)