@graph-render/core 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +6 -0
- package/CHANGELOG.md +45 -0
- package/dist/edges/collision.d.ts +8 -0
- package/dist/edges/collision.d.ts.map +1 -0
- package/dist/edges/collision.js +26 -0
- package/dist/edges/collision.js.map +1 -0
- package/dist/edges/geometry.d.ts +22 -0
- package/dist/edges/geometry.d.ts.map +1 -0
- package/dist/edges/geometry.js +75 -0
- package/dist/edges/geometry.js.map +1 -0
- package/dist/edges/index.d.ts +4 -0
- package/dist/edges/index.d.ts.map +1 -0
- package/dist/edges/index.js +4 -0
- package/dist/edges/index.js.map +1 -0
- package/dist/edges/pathBuilder.d.ts +11 -0
- package/dist/edges/pathBuilder.d.ts.map +1 -0
- package/dist/edges/pathBuilder.js +114 -0
- package/dist/edges/pathBuilder.js.map +1 -0
- package/dist/edges/pathCalculation.d.ts +14 -0
- package/dist/edges/pathCalculation.d.ts.map +1 -0
- package/dist/edges/pathCalculation.js +47 -0
- package/dist/edges/pathCalculation.js.map +1 -0
- package/dist/edges/routing.d.ts +6 -0
- package/dist/edges/routing.d.ts.map +1 -0
- package/dist/edges/routing.js +243 -0
- package/dist/edges/routing.js.map +1 -0
- package/dist/edges/sideSelection.d.ts +17 -0
- package/dist/edges/sideSelection.d.ts.map +1 -0
- package/dist/edges/sideSelection.js +45 -0
- package/dist/edges/sideSelection.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/layouts/centered.d.ts +6 -0
- package/dist/layouts/centered.d.ts.map +1 -0
- package/dist/layouts/centered.js +69 -0
- package/dist/layouts/centered.js.map +1 -0
- package/dist/layouts/compactBracket.d.ts +3 -0
- package/dist/layouts/compactBracket.d.ts.map +1 -0
- package/dist/layouts/compactBracket.js +7 -0
- package/dist/layouts/compactBracket.js.map +1 -0
- package/dist/layouts/dag.d.ts +3 -0
- package/dist/layouts/dag.d.ts.map +1 -0
- package/dist/layouts/dag.js +52 -0
- package/dist/layouts/dag.js.map +1 -0
- package/dist/layouts/forceDirected.d.ts +3 -0
- package/dist/layouts/forceDirected.d.ts.map +1 -0
- package/dist/layouts/forceDirected.js +176 -0
- package/dist/layouts/forceDirected.js.map +1 -0
- package/dist/layouts/grid.d.ts +6 -0
- package/dist/layouts/grid.d.ts.map +1 -0
- package/dist/layouts/grid.js +34 -0
- package/dist/layouts/grid.js.map +1 -0
- package/dist/layouts/index.d.ts +12 -0
- package/dist/layouts/index.d.ts.map +1 -0
- package/dist/layouts/index.js +88 -0
- package/dist/layouts/index.js.map +1 -0
- package/dist/layouts/orthogonalFlow.d.ts +3 -0
- package/dist/layouts/orthogonalFlow.d.ts.map +1 -0
- package/dist/layouts/orthogonalFlow.js +81 -0
- package/dist/layouts/orthogonalFlow.js.map +1 -0
- package/dist/layouts/radialTree.d.ts +3 -0
- package/dist/layouts/radialTree.d.ts.map +1 -0
- package/dist/layouts/radialTree.js +45 -0
- package/dist/layouts/radialTree.js.map +1 -0
- package/dist/layouts/tree.d.ts +6 -0
- package/dist/layouts/tree.d.ts.map +1 -0
- package/dist/layouts/tree.js +19 -0
- package/dist/layouts/tree.js.map +1 -0
- package/dist/layouts/treeAlignment.d.ts +10 -0
- package/dist/layouts/treeAlignment.d.ts.map +1 -0
- package/dist/layouts/treeAlignment.js +69 -0
- package/dist/layouts/treeAlignment.js.map +1 -0
- package/dist/layouts/treePositioning.d.ts +14 -0
- package/dist/layouts/treePositioning.d.ts.map +1 -0
- package/dist/layouts/treePositioning.js +30 -0
- package/dist/layouts/treePositioning.js.map +1 -0
- package/dist/layouts/treeTopology.d.ts +29 -0
- package/dist/layouts/treeTopology.d.ts.map +1 -0
- package/dist/layouts/treeTopology.js +137 -0
- package/dist/layouts/treeTopology.js.map +1 -0
- package/dist/rendering/defaultRenderers.d.ts +10 -0
- package/dist/rendering/defaultRenderers.d.ts.map +1 -0
- package/dist/rendering/defaultRenderers.js +85 -0
- package/dist/rendering/defaultRenderers.js.map +1 -0
- package/dist/rendering/index.d.ts +4 -0
- package/dist/rendering/index.d.ts.map +1 -0
- package/dist/rendering/index.js +4 -0
- package/dist/rendering/index.js.map +1 -0
- package/dist/rendering/svg.d.ts +7 -0
- package/dist/rendering/svg.d.ts.map +1 -0
- package/dist/rendering/svg.js +256 -0
- package/dist/rendering/svg.js.map +1 -0
- package/dist/rendering/utils.d.ts +5 -0
- package/dist/rendering/utils.d.ts.map +1 -0
- package/dist/rendering/utils.js +33 -0
- package/dist/rendering/utils.js.map +1 -0
- package/dist/utils/config.d.ts +36 -0
- package/dist/utils/config.d.ts.map +1 -0
- package/dist/utils/config.js +115 -0
- package/dist/utils/config.js.map +1 -0
- package/dist/utils/constants.d.ts +15 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +19 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/graphParser.d.ts +16 -0
- package/dist/utils/graphParser.d.ts.map +1 -0
- package/dist/utils/graphParser.js +277 -0
- package/dist/utils/graphParser.js.map +1 -0
- package/dist/utils/graphTraversal.d.ts +13 -0
- package/dist/utils/graphTraversal.d.ts.map +1 -0
- package/dist/utils/graphTraversal.js +28 -0
- package/dist/utils/graphTraversal.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/nodeMetrics.d.ts +8 -0
- package/dist/utils/nodeMetrics.d.ts.map +1 -0
- package/dist/utils/nodeMetrics.js +12 -0
- package/dist/utils/nodeMetrics.js.map +1 -0
- package/dist/utils/nodeSizing.d.ts +3 -0
- package/dist/utils/nodeSizing.d.ts.map +1 -0
- package/dist/utils/nodeSizing.js +77 -0
- package/dist/utils/nodeSizing.js.map +1 -0
- package/package.json +29 -0
- package/project.json +32 -0
- package/src/edges/collision.ts +31 -0
- package/src/edges/geometry.ts +85 -0
- package/src/edges/index.ts +3 -0
- package/src/edges/pathBuilder.ts +136 -0
- package/src/edges/pathCalculation.ts +69 -0
- package/src/edges/routing.ts +459 -0
- package/src/edges/sideSelection.ts +67 -0
- package/src/index.ts +50 -0
- package/src/layouts/centered.ts +114 -0
- package/src/layouts/compactBracket.ts +14 -0
- package/src/layouts/dag.ts +76 -0
- package/src/layouts/forceDirected.ts +224 -0
- package/src/layouts/grid.ts +50 -0
- package/src/layouts/index.ts +148 -0
- package/src/layouts/orthogonalFlow.ts +112 -0
- package/src/layouts/radialTree.ts +77 -0
- package/src/layouts/tree.ts +35 -0
- package/src/layouts/treeAlignment.ts +107 -0
- package/src/layouts/treePositioning.ts +55 -0
- package/src/layouts/treeTopology.ts +184 -0
- package/src/rendering/defaultRenderers.ts +110 -0
- package/src/rendering/index.ts +3 -0
- package/src/rendering/svg.ts +346 -0
- package/src/rendering/utils.ts +41 -0
- package/src/utils/config.ts +198 -0
- package/src/utils/constants.ts +24 -0
- package/src/utils/graphParser.ts +495 -0
- package/src/utils/graphTraversal.ts +32 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/nodeMetrics.ts +23 -0
- package/src/utils/nodeSizing.ts +97 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NodeData, EdgeData, PositionedNode, LayoutDirection } from '@graph-render/types';
|
|
2
|
+
import { DEFAULT_PADDING, DEFAULT_NODE_GAP } from '../utils';
|
|
3
|
+
import {
|
|
4
|
+
assertHierarchicalGraph,
|
|
5
|
+
buildGraphTopology,
|
|
6
|
+
findRootNodes,
|
|
7
|
+
assignNodesToLevels,
|
|
8
|
+
groupNodesByLevel,
|
|
9
|
+
} from './treeTopology';
|
|
10
|
+
import { calculateTreeMetrics } from './treePositioning';
|
|
11
|
+
import { positionNodesInLevels, alignNodesToParents } from './treeAlignment';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Layout nodes in a tree/hierarchical structure
|
|
15
|
+
*/
|
|
16
|
+
export const treeLayout = (
|
|
17
|
+
nodes: NodeData[],
|
|
18
|
+
edges: EdgeData[],
|
|
19
|
+
pad: number = DEFAULT_PADDING,
|
|
20
|
+
gap: number = DEFAULT_NODE_GAP,
|
|
21
|
+
direction: LayoutDirection = LayoutDirection.LTR,
|
|
22
|
+
containerHeight?: number
|
|
23
|
+
): PositionedNode[] => {
|
|
24
|
+
assertHierarchicalGraph(nodes, edges);
|
|
25
|
+
|
|
26
|
+
const { incoming, outgoing } = buildGraphTopology(edges);
|
|
27
|
+
const rootIds = findRootNodes(nodes, incoming);
|
|
28
|
+
const levelMap = assignNodesToLevels(nodes, rootIds, outgoing);
|
|
29
|
+
const levels = groupNodesByLevel(nodes, levelMap);
|
|
30
|
+
const metrics = calculateTreeMetrics(nodes, levels, gap, pad, containerHeight);
|
|
31
|
+
|
|
32
|
+
const positioned = positionNodesInLevels(nodes, levels, levelMap, metrics, gap, pad, direction);
|
|
33
|
+
|
|
34
|
+
return alignNodesToParents(positioned, edges, levels, metrics.maxLevel);
|
|
35
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NodeData,
|
|
3
|
+
EdgeData,
|
|
4
|
+
PositionedNode,
|
|
5
|
+
TreeMetrics,
|
|
6
|
+
LayoutDirection,
|
|
7
|
+
} from '@graph-render/types';
|
|
8
|
+
import { DEFAULT_NODE_SIZE } from '../utils';
|
|
9
|
+
import { calculateXPosition, calculateYPosition } from './treePositioning';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Position all nodes initially based on their level
|
|
13
|
+
*/
|
|
14
|
+
export const positionNodesInLevels = (
|
|
15
|
+
nodes: NodeData[],
|
|
16
|
+
levels: string[][],
|
|
17
|
+
levelMap: Map<string, number>,
|
|
18
|
+
metrics: TreeMetrics,
|
|
19
|
+
gap: number,
|
|
20
|
+
padding: number,
|
|
21
|
+
direction: LayoutDirection
|
|
22
|
+
): PositionedNode[] => {
|
|
23
|
+
return nodes.map((node) => {
|
|
24
|
+
if (node.position) return node as PositionedNode;
|
|
25
|
+
|
|
26
|
+
const level = levelMap.get(node.id) ?? 0;
|
|
27
|
+
const levelNodes = levels[level] ?? [];
|
|
28
|
+
const idx = levelNodes.indexOf(node.id);
|
|
29
|
+
const nodeWidth = node.size?.width ?? DEFAULT_NODE_SIZE.width;
|
|
30
|
+
|
|
31
|
+
const x = calculateXPosition(level, metrics.maxLevel, nodeWidth, gap, padding, direction);
|
|
32
|
+
const y = calculateYPosition(
|
|
33
|
+
idx,
|
|
34
|
+
levelNodes,
|
|
35
|
+
metrics.maxNodeHeight,
|
|
36
|
+
gap,
|
|
37
|
+
metrics.totalHeight,
|
|
38
|
+
metrics.baseY
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return { ...node, position: { x, y } } as PositionedNode;
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// FIX: deleted the file-private `getParentNodes` helper. It was replaced by
|
|
46
|
+
// the `incomingByTarget` Map built inside `alignNodesToParents` (O(n+e) vs the
|
|
47
|
+
// previous O(n×e)) and was never exported, so no external callers exist.
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Calculate average Y center of parent nodes
|
|
51
|
+
*/
|
|
52
|
+
const calculateParentCentersAverage = (
|
|
53
|
+
parentIds: string[],
|
|
54
|
+
posMap: Map<string, PositionedNode>
|
|
55
|
+
): number | null => {
|
|
56
|
+
const centers = parentIds
|
|
57
|
+
.map((src) => posMap.get(src))
|
|
58
|
+
.filter((n): n is PositionedNode => !!n)
|
|
59
|
+
.map((n) => n.position.y + (n.size?.height ?? DEFAULT_NODE_SIZE.height) / 2);
|
|
60
|
+
|
|
61
|
+
if (!centers.length) return null;
|
|
62
|
+
return centers.reduce((a, b) => a + b, 0) / centers.length;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Align nodes with multiple parents to their parent's average position
|
|
67
|
+
*/
|
|
68
|
+
export const alignNodesToParents = (
|
|
69
|
+
positioned: PositionedNode[],
|
|
70
|
+
edges: EdgeData[],
|
|
71
|
+
levels: string[][],
|
|
72
|
+
maxLevel: number
|
|
73
|
+
): PositionedNode[] => {
|
|
74
|
+
const posMap = new Map<string, PositionedNode>();
|
|
75
|
+
positioned.forEach((n) => posMap.set(n.id, n));
|
|
76
|
+
|
|
77
|
+
// FIX: build a target→sources index once instead of calling
|
|
78
|
+
// edges.filter(e => e.target === id) for every node (was O(nodes × edges)).
|
|
79
|
+
const incomingByTarget = new Map<string, string[]>();
|
|
80
|
+
edges.forEach((e) => {
|
|
81
|
+
incomingByTarget.set(e.target, [...(incomingByTarget.get(e.target) ?? []), e.source]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (let level = 1; level <= maxLevel; level += 1) {
|
|
85
|
+
const ids = levels[level] ?? [];
|
|
86
|
+
ids.forEach((id) => {
|
|
87
|
+
const node = posMap.get(id);
|
|
88
|
+
if (!node) return;
|
|
89
|
+
|
|
90
|
+
const parentIds = incomingByTarget.get(id) ?? [];
|
|
91
|
+
if (parentIds.length < 2) return;
|
|
92
|
+
|
|
93
|
+
const avgCenterY = calculateParentCentersAverage(parentIds, posMap);
|
|
94
|
+
if (avgCenterY === null) return;
|
|
95
|
+
|
|
96
|
+
const h = node.size?.height ?? DEFAULT_NODE_SIZE.height;
|
|
97
|
+
const newY = avgCenterY - h / 2;
|
|
98
|
+
const updated = {
|
|
99
|
+
...node,
|
|
100
|
+
position: { ...node.position, y: newY },
|
|
101
|
+
} as PositionedNode;
|
|
102
|
+
posMap.set(id, updated);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return Array.from(posMap.values());
|
|
107
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NodeData, TreeMetrics, LayoutDirection } from '@graph-render/types';
|
|
2
|
+
import { getMaxNodeHeight } from '../utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculate tree layout metrics
|
|
6
|
+
*/
|
|
7
|
+
export const calculateTreeMetrics = (
|
|
8
|
+
nodes: NodeData[],
|
|
9
|
+
levels: string[][],
|
|
10
|
+
gap: number,
|
|
11
|
+
padding: number,
|
|
12
|
+
containerHeight?: number
|
|
13
|
+
): TreeMetrics => {
|
|
14
|
+
const maxLevel = levels.length ? levels.length - 1 : 0;
|
|
15
|
+
const maxNodeHeight = getMaxNodeHeight(nodes);
|
|
16
|
+
const maxLevelCount = Math.max(1, ...levels.map((l) => l?.length ?? 0));
|
|
17
|
+
const totalHeight = maxLevelCount * maxNodeHeight + (maxLevelCount - 1) * gap;
|
|
18
|
+
const baseY =
|
|
19
|
+
containerHeight != null ? Math.max(padding, (containerHeight - totalHeight) / 2) : padding;
|
|
20
|
+
|
|
21
|
+
return { maxLevel, maxNodeHeight, maxLevelCount, totalHeight, baseY };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calculate X position based on level and direction
|
|
26
|
+
*/
|
|
27
|
+
export const calculateXPosition = (
|
|
28
|
+
level: number,
|
|
29
|
+
maxLevel: number,
|
|
30
|
+
nodeWidth: number,
|
|
31
|
+
gap: number,
|
|
32
|
+
padding: number,
|
|
33
|
+
direction: LayoutDirection
|
|
34
|
+
): number => {
|
|
35
|
+
const ltrX = padding + level * (nodeWidth + gap);
|
|
36
|
+
const rtlX = padding + (maxLevel - level) * (nodeWidth + gap);
|
|
37
|
+
return direction === LayoutDirection.RTL ? rtlX : ltrX;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Calculate Y position for a node in a level
|
|
42
|
+
*/
|
|
43
|
+
export const calculateYPosition = (
|
|
44
|
+
nodeIndex: number,
|
|
45
|
+
levelNodes: string[],
|
|
46
|
+
maxNodeHeight: number,
|
|
47
|
+
gap: number,
|
|
48
|
+
totalHeight: number,
|
|
49
|
+
baseY: number
|
|
50
|
+
): number => {
|
|
51
|
+
const levelHeight =
|
|
52
|
+
(levelNodes.length || 1) * maxNodeHeight + Math.max(0, (levelNodes.length || 1) - 1) * gap;
|
|
53
|
+
const levelStartY = baseY + (totalHeight - levelHeight) / 2;
|
|
54
|
+
return levelStartY + nodeIndex * (maxNodeHeight + gap);
|
|
55
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { NodeData, EdgeData, GraphTopology } from '@graph-render/types';
|
|
2
|
+
|
|
3
|
+
export const assertHierarchicalGraph = (nodes: NodeData[], edges: EdgeData[]): void => {
|
|
4
|
+
if (!nodes.length) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const inDegree = new Map(nodes.map((node) => [node.id, 0]));
|
|
9
|
+
const outgoing = new Map<string, string[]>();
|
|
10
|
+
|
|
11
|
+
for (const edge of edges) {
|
|
12
|
+
if (!inDegree.has(edge.source) || !inDegree.has(edge.target)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Tree layout requires every edge to reference existing nodes. Invalid edge: ${edge.source} -> ${edge.target}.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
|
19
|
+
outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const queue = Array.from(inDegree.entries())
|
|
23
|
+
.filter(([, degree]) => degree === 0)
|
|
24
|
+
.map(([nodeId]) => nodeId);
|
|
25
|
+
|
|
26
|
+
if (!queue.length) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'Tree layout requires at least one root node and does not support cyclic graphs.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let processed = 0;
|
|
33
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
34
|
+
const current = queue[index];
|
|
35
|
+
processed += 1;
|
|
36
|
+
|
|
37
|
+
for (const child of outgoing.get(current) ?? []) {
|
|
38
|
+
const nextDegree = (inDegree.get(child) ?? 0) - 1;
|
|
39
|
+
inDegree.set(child, nextDegree);
|
|
40
|
+
if (nextDegree === 0) {
|
|
41
|
+
queue.push(child);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (processed !== nodes.length) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'Tree layout requires an acyclic graph. Cycles were detected in the input graph.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build graph topology from edges
|
|
55
|
+
*/
|
|
56
|
+
export const buildGraphTopology = (edges: EdgeData[]): GraphTopology => {
|
|
57
|
+
const incoming = new Map<string, number>();
|
|
58
|
+
const outgoing = new Map<string, string[]>();
|
|
59
|
+
|
|
60
|
+
edges.forEach((e) => {
|
|
61
|
+
incoming.set(e.target, (incoming.get(e.target) ?? 0) + 1);
|
|
62
|
+
outgoing.set(e.source, [...(outgoing.get(e.source) ?? []), e.target]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return { incoming, outgoing };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Find root nodes (nodes with no incoming edges)
|
|
70
|
+
*/
|
|
71
|
+
export const findRootNodes = (nodes: NodeData[], incoming: Map<string, number>): string[] => {
|
|
72
|
+
const roots = nodes.filter((n) => (incoming.get(n.id) ?? 0) === 0);
|
|
73
|
+
return roots.map((r) => r.id);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Assign nodes to levels using BFS
|
|
78
|
+
*/
|
|
79
|
+
export const assignNodesToLevels = (
|
|
80
|
+
nodes: NodeData[],
|
|
81
|
+
rootIds: string[],
|
|
82
|
+
outgoing: Map<string, string[]>
|
|
83
|
+
): Map<string, number> => {
|
|
84
|
+
const levelMap = new Map<string, number>();
|
|
85
|
+
const queue = [...rootIds];
|
|
86
|
+
rootIds.forEach((id) => levelMap.set(id, 0));
|
|
87
|
+
|
|
88
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
89
|
+
const current = queue[index];
|
|
90
|
+
const level = levelMap.get(current) ?? 0;
|
|
91
|
+
for (const child of outgoing.get(current) ?? []) {
|
|
92
|
+
if (!levelMap.has(child)) {
|
|
93
|
+
levelMap.set(child, level + 1);
|
|
94
|
+
queue.push(child);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return levelMap;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Assign DAG-style max-depth levels using a single Kahn's BFS pass.
|
|
104
|
+
* Validates edge references and detects cycles in the same traversal,
|
|
105
|
+
* replacing the three-pass pattern of assertHierarchicalGraph +
|
|
106
|
+
* buildGraphTopology + an inline BFS.
|
|
107
|
+
*/
|
|
108
|
+
export const assignDagLevels = (
|
|
109
|
+
nodes: NodeData[],
|
|
110
|
+
edges: EdgeData[]
|
|
111
|
+
): { levels: Map<string, number>; outgoing: Map<string, string[]> } => {
|
|
112
|
+
if (!nodes.length) {
|
|
113
|
+
return { levels: new Map(), outgoing: new Map() };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const nodeSet = new Set(nodes.map((n) => n.id));
|
|
117
|
+
const inDegree = new Map<string, number>(nodes.map((n) => [n.id, 0]));
|
|
118
|
+
const outgoing = new Map<string, string[]>();
|
|
119
|
+
|
|
120
|
+
for (const edge of edges) {
|
|
121
|
+
if (!nodeSet.has(edge.source) || !nodeSet.has(edge.target)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`DAG layout requires every edge to reference existing nodes. Invalid edge: ${edge.source} -> ${edge.target}.`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
|
127
|
+
const children = outgoing.get(edge.source) ?? [];
|
|
128
|
+
children.push(edge.target);
|
|
129
|
+
outgoing.set(edge.source, children);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const levels = new Map<string, number>();
|
|
133
|
+
const queue = nodes.filter((n) => inDegree.get(n.id) === 0).map((n) => n.id);
|
|
134
|
+
|
|
135
|
+
if (!queue.length) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'DAG layout requires at least one root node and does not support cyclic graphs.'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
queue.forEach((id) => levels.set(id, 0));
|
|
142
|
+
let processed = 0;
|
|
143
|
+
|
|
144
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
145
|
+
const current = queue[index];
|
|
146
|
+
processed += 1;
|
|
147
|
+
const currentLevel = levels.get(current) ?? 0;
|
|
148
|
+
|
|
149
|
+
for (const child of outgoing.get(current) ?? []) {
|
|
150
|
+
levels.set(child, Math.max(levels.get(child) ?? 0, currentLevel + 1));
|
|
151
|
+
const nextInDegree = (inDegree.get(child) ?? 0) - 1;
|
|
152
|
+
inDegree.set(child, nextInDegree);
|
|
153
|
+
if (nextInDegree === 0) {
|
|
154
|
+
queue.push(child);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (processed !== nodes.length) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'DAG layout requires an acyclic graph. Cycles were detected in the input graph.'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { levels, outgoing };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Group nodes by their level
|
|
170
|
+
*/
|
|
171
|
+
export const groupNodesByLevel = (nodes: NodeData[], levelMap: Map<string, number>): string[][] => {
|
|
172
|
+
const levels: string[][] = [];
|
|
173
|
+
nodes.forEach((n) => {
|
|
174
|
+
const l = levelMap.get(n.id);
|
|
175
|
+
if (l == null) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Tree layout could not assign a level to node "${n.id}". Ensure the graph is connected from at least one root and acyclic.`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (!levels[l]) levels[l] = [];
|
|
181
|
+
levels[l].push(n.id);
|
|
182
|
+
});
|
|
183
|
+
return levels;
|
|
184
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { PositionedNode, EdgeType } from '@graph-render/types';
|
|
2
|
+
import type { NodeRenderer, EdgeRenderer } from '@graph-render/types';
|
|
3
|
+
import { escapeXml } from './utils';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_NODE_WIDTH,
|
|
6
|
+
DEFAULT_NODE_HEIGHT,
|
|
7
|
+
DEFAULT_NODE_RADIUS,
|
|
8
|
+
DEFAULT_NODE_FILL,
|
|
9
|
+
DEFAULT_NODE_STROKE,
|
|
10
|
+
DEFAULT_TEXT_FILL,
|
|
11
|
+
DEFAULT_TEXT_SIZE,
|
|
12
|
+
} from '../utils';
|
|
13
|
+
|
|
14
|
+
const MAX_RENDER_LABEL_LENGTH = 2_000;
|
|
15
|
+
const MAX_RENDER_LABEL_LINES = 8;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract label from node data
|
|
19
|
+
*/
|
|
20
|
+
const getNodeLabel = (node: PositionedNode): string => {
|
|
21
|
+
if (typeof node.label === 'string' || typeof node.label === 'number') {
|
|
22
|
+
return String(node.label);
|
|
23
|
+
}
|
|
24
|
+
return node.id;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const getRenderableLabelLines = (label: string): string[] => {
|
|
28
|
+
const truncated = label.slice(0, MAX_RENDER_LABEL_LENGTH);
|
|
29
|
+
const lines = truncated
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.slice(0, MAX_RENDER_LABEL_LINES)
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter((line) => line.length > 0);
|
|
34
|
+
|
|
35
|
+
return lines.length ? lines : [''];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get node dimensions
|
|
40
|
+
*/
|
|
41
|
+
const getNodeDimensions = (node: PositionedNode): { width: number; height: number } => {
|
|
42
|
+
return {
|
|
43
|
+
width: node.size?.width ?? DEFAULT_NODE_WIDTH,
|
|
44
|
+
height: node.size?.height ?? DEFAULT_NODE_HEIGHT,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create SVG rectangle element for node
|
|
50
|
+
*/
|
|
51
|
+
const createNodeRect = (width: number, height: number, radius: number): string => {
|
|
52
|
+
return `<rect x="0" y="0" width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="${DEFAULT_NODE_FILL}" stroke="${DEFAULT_NODE_STROKE}" stroke-width="1" />`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create SVG text element for node label
|
|
57
|
+
*/
|
|
58
|
+
const createNodeText = (label: string, width: number, height: number): string => {
|
|
59
|
+
const lines = getRenderableLabelLines(label);
|
|
60
|
+
const x = width / 2;
|
|
61
|
+
const lineHeight = DEFAULT_TEXT_SIZE + 4;
|
|
62
|
+
const firstLineY = height / 2 - ((lines.length - 1) * lineHeight) / 2 + 4;
|
|
63
|
+
const tspans = lines
|
|
64
|
+
.map(
|
|
65
|
+
(line, index) =>
|
|
66
|
+
`<tspan x="${x}" y="${firstLineY + index * lineHeight}">${escapeXml(line)}</tspan>`
|
|
67
|
+
)
|
|
68
|
+
.join('');
|
|
69
|
+
|
|
70
|
+
return `<text x="${x}" fill="${DEFAULT_TEXT_FILL}" font-size="${DEFAULT_TEXT_SIZE}" font-weight="600" text-anchor="middle">${tspans}</text>`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Default node renderer - creates a rounded rectangle with centered text
|
|
75
|
+
*/
|
|
76
|
+
export const defaultNodeRenderer: NodeRenderer = (node) => {
|
|
77
|
+
const { width, height } = getNodeDimensions(node);
|
|
78
|
+
const label = getNodeLabel(node);
|
|
79
|
+
const rect = createNodeRect(width, height, DEFAULT_NODE_RADIUS);
|
|
80
|
+
const text = createNodeText(label, width, height);
|
|
81
|
+
return [rect, text].join('');
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Determine if edge should have an arrow marker
|
|
86
|
+
*/
|
|
87
|
+
const shouldShowArrow = (edgeType?: EdgeType): boolean => {
|
|
88
|
+
return edgeType === EdgeType.Directed;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create marker-end attribute for directed edges
|
|
93
|
+
*/
|
|
94
|
+
const createMarkerAttribute = (markerId: string): string => {
|
|
95
|
+
return ` marker-end="url(#${markerId})"`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Default edge renderer - creates an SVG path with optional arrow marker
|
|
100
|
+
*/
|
|
101
|
+
export const defaultEdgeRenderer: EdgeRenderer = (edge, pathD, theme) => {
|
|
102
|
+
const marker =
|
|
103
|
+
shouldShowArrow(edge.type) && theme.markerId ? createMarkerAttribute(theme.markerId) : '';
|
|
104
|
+
const path = `<path d="${pathD}" stroke="${theme.edgeColor}" stroke-width="${theme.edgeWidth}" fill="none"${marker}/>`;
|
|
105
|
+
const label =
|
|
106
|
+
edge.label != null && edge.labelPosition
|
|
107
|
+
? `<text x="${edge.labelPosition.x}" y="${edge.labelPosition.y - 6}" fill="${theme.edgeLabelColor}" font-size="12" font-weight="600" text-anchor="middle">${escapeXml(String(edge.label))}</text>`
|
|
108
|
+
: '';
|
|
109
|
+
return `${path}${label}`;
|
|
110
|
+
};
|