Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<svg width="1920" height="1280"></svg>
<svg width="100%" height="100%" viewBox="-750 -250 1500 500"></svg>
230 changes: 203 additions & 27 deletions src/app/component/dependency-graph/dependency-graph.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { DataStore } from 'src/app/model/data-store';

export interface graphNodes {
id: string;
relativeLevel: number;
relativeCount: number;
}

export interface graphLinks {
Expand All @@ -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<DataStore> = {};
Expand All @@ -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');
}
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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;
Expand All @@ -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;
});

Expand All @@ -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;
}
}
}
}
}
Loading