@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.
Files changed (161) hide show
  1. package/.eslintrc.json +6 -0
  2. package/CHANGELOG.md +45 -0
  3. package/dist/edges/collision.d.ts +8 -0
  4. package/dist/edges/collision.d.ts.map +1 -0
  5. package/dist/edges/collision.js +26 -0
  6. package/dist/edges/collision.js.map +1 -0
  7. package/dist/edges/geometry.d.ts +22 -0
  8. package/dist/edges/geometry.d.ts.map +1 -0
  9. package/dist/edges/geometry.js +75 -0
  10. package/dist/edges/geometry.js.map +1 -0
  11. package/dist/edges/index.d.ts +4 -0
  12. package/dist/edges/index.d.ts.map +1 -0
  13. package/dist/edges/index.js +4 -0
  14. package/dist/edges/index.js.map +1 -0
  15. package/dist/edges/pathBuilder.d.ts +11 -0
  16. package/dist/edges/pathBuilder.d.ts.map +1 -0
  17. package/dist/edges/pathBuilder.js +114 -0
  18. package/dist/edges/pathBuilder.js.map +1 -0
  19. package/dist/edges/pathCalculation.d.ts +14 -0
  20. package/dist/edges/pathCalculation.d.ts.map +1 -0
  21. package/dist/edges/pathCalculation.js +47 -0
  22. package/dist/edges/pathCalculation.js.map +1 -0
  23. package/dist/edges/routing.d.ts +6 -0
  24. package/dist/edges/routing.d.ts.map +1 -0
  25. package/dist/edges/routing.js +243 -0
  26. package/dist/edges/routing.js.map +1 -0
  27. package/dist/edges/sideSelection.d.ts +17 -0
  28. package/dist/edges/sideSelection.d.ts.map +1 -0
  29. package/dist/edges/sideSelection.js +45 -0
  30. package/dist/edges/sideSelection.js.map +1 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +6 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/layouts/centered.d.ts +6 -0
  36. package/dist/layouts/centered.d.ts.map +1 -0
  37. package/dist/layouts/centered.js +69 -0
  38. package/dist/layouts/centered.js.map +1 -0
  39. package/dist/layouts/compactBracket.d.ts +3 -0
  40. package/dist/layouts/compactBracket.d.ts.map +1 -0
  41. package/dist/layouts/compactBracket.js +7 -0
  42. package/dist/layouts/compactBracket.js.map +1 -0
  43. package/dist/layouts/dag.d.ts +3 -0
  44. package/dist/layouts/dag.d.ts.map +1 -0
  45. package/dist/layouts/dag.js +52 -0
  46. package/dist/layouts/dag.js.map +1 -0
  47. package/dist/layouts/forceDirected.d.ts +3 -0
  48. package/dist/layouts/forceDirected.d.ts.map +1 -0
  49. package/dist/layouts/forceDirected.js +176 -0
  50. package/dist/layouts/forceDirected.js.map +1 -0
  51. package/dist/layouts/grid.d.ts +6 -0
  52. package/dist/layouts/grid.d.ts.map +1 -0
  53. package/dist/layouts/grid.js +34 -0
  54. package/dist/layouts/grid.js.map +1 -0
  55. package/dist/layouts/index.d.ts +12 -0
  56. package/dist/layouts/index.d.ts.map +1 -0
  57. package/dist/layouts/index.js +88 -0
  58. package/dist/layouts/index.js.map +1 -0
  59. package/dist/layouts/orthogonalFlow.d.ts +3 -0
  60. package/dist/layouts/orthogonalFlow.d.ts.map +1 -0
  61. package/dist/layouts/orthogonalFlow.js +81 -0
  62. package/dist/layouts/orthogonalFlow.js.map +1 -0
  63. package/dist/layouts/radialTree.d.ts +3 -0
  64. package/dist/layouts/radialTree.d.ts.map +1 -0
  65. package/dist/layouts/radialTree.js +45 -0
  66. package/dist/layouts/radialTree.js.map +1 -0
  67. package/dist/layouts/tree.d.ts +6 -0
  68. package/dist/layouts/tree.d.ts.map +1 -0
  69. package/dist/layouts/tree.js +19 -0
  70. package/dist/layouts/tree.js.map +1 -0
  71. package/dist/layouts/treeAlignment.d.ts +10 -0
  72. package/dist/layouts/treeAlignment.d.ts.map +1 -0
  73. package/dist/layouts/treeAlignment.js +69 -0
  74. package/dist/layouts/treeAlignment.js.map +1 -0
  75. package/dist/layouts/treePositioning.d.ts +14 -0
  76. package/dist/layouts/treePositioning.d.ts.map +1 -0
  77. package/dist/layouts/treePositioning.js +30 -0
  78. package/dist/layouts/treePositioning.js.map +1 -0
  79. package/dist/layouts/treeTopology.d.ts +29 -0
  80. package/dist/layouts/treeTopology.d.ts.map +1 -0
  81. package/dist/layouts/treeTopology.js +137 -0
  82. package/dist/layouts/treeTopology.js.map +1 -0
  83. package/dist/rendering/defaultRenderers.d.ts +10 -0
  84. package/dist/rendering/defaultRenderers.d.ts.map +1 -0
  85. package/dist/rendering/defaultRenderers.js +85 -0
  86. package/dist/rendering/defaultRenderers.js.map +1 -0
  87. package/dist/rendering/index.d.ts +4 -0
  88. package/dist/rendering/index.d.ts.map +1 -0
  89. package/dist/rendering/index.js +4 -0
  90. package/dist/rendering/index.js.map +1 -0
  91. package/dist/rendering/svg.d.ts +7 -0
  92. package/dist/rendering/svg.d.ts.map +1 -0
  93. package/dist/rendering/svg.js +256 -0
  94. package/dist/rendering/svg.js.map +1 -0
  95. package/dist/rendering/utils.d.ts +5 -0
  96. package/dist/rendering/utils.d.ts.map +1 -0
  97. package/dist/rendering/utils.js +33 -0
  98. package/dist/rendering/utils.js.map +1 -0
  99. package/dist/utils/config.d.ts +36 -0
  100. package/dist/utils/config.d.ts.map +1 -0
  101. package/dist/utils/config.js +115 -0
  102. package/dist/utils/config.js.map +1 -0
  103. package/dist/utils/constants.d.ts +15 -0
  104. package/dist/utils/constants.d.ts.map +1 -0
  105. package/dist/utils/constants.js +19 -0
  106. package/dist/utils/constants.js.map +1 -0
  107. package/dist/utils/graphParser.d.ts +16 -0
  108. package/dist/utils/graphParser.d.ts.map +1 -0
  109. package/dist/utils/graphParser.js +277 -0
  110. package/dist/utils/graphParser.js.map +1 -0
  111. package/dist/utils/graphTraversal.d.ts +13 -0
  112. package/dist/utils/graphTraversal.d.ts.map +1 -0
  113. package/dist/utils/graphTraversal.js +28 -0
  114. package/dist/utils/graphTraversal.js.map +1 -0
  115. package/dist/utils/index.d.ts +8 -0
  116. package/dist/utils/index.d.ts.map +1 -0
  117. package/dist/utils/index.js +7 -0
  118. package/dist/utils/index.js.map +1 -0
  119. package/dist/utils/nodeMetrics.d.ts +8 -0
  120. package/dist/utils/nodeMetrics.d.ts.map +1 -0
  121. package/dist/utils/nodeMetrics.js +12 -0
  122. package/dist/utils/nodeMetrics.js.map +1 -0
  123. package/dist/utils/nodeSizing.d.ts +3 -0
  124. package/dist/utils/nodeSizing.d.ts.map +1 -0
  125. package/dist/utils/nodeSizing.js +77 -0
  126. package/dist/utils/nodeSizing.js.map +1 -0
  127. package/package.json +29 -0
  128. package/project.json +32 -0
  129. package/src/edges/collision.ts +31 -0
  130. package/src/edges/geometry.ts +85 -0
  131. package/src/edges/index.ts +3 -0
  132. package/src/edges/pathBuilder.ts +136 -0
  133. package/src/edges/pathCalculation.ts +69 -0
  134. package/src/edges/routing.ts +459 -0
  135. package/src/edges/sideSelection.ts +67 -0
  136. package/src/index.ts +50 -0
  137. package/src/layouts/centered.ts +114 -0
  138. package/src/layouts/compactBracket.ts +14 -0
  139. package/src/layouts/dag.ts +76 -0
  140. package/src/layouts/forceDirected.ts +224 -0
  141. package/src/layouts/grid.ts +50 -0
  142. package/src/layouts/index.ts +148 -0
  143. package/src/layouts/orthogonalFlow.ts +112 -0
  144. package/src/layouts/radialTree.ts +77 -0
  145. package/src/layouts/tree.ts +35 -0
  146. package/src/layouts/treeAlignment.ts +107 -0
  147. package/src/layouts/treePositioning.ts +55 -0
  148. package/src/layouts/treeTopology.ts +184 -0
  149. package/src/rendering/defaultRenderers.ts +110 -0
  150. package/src/rendering/index.ts +3 -0
  151. package/src/rendering/svg.ts +346 -0
  152. package/src/rendering/utils.ts +41 -0
  153. package/src/utils/config.ts +198 -0
  154. package/src/utils/constants.ts +24 -0
  155. package/src/utils/graphParser.ts +495 -0
  156. package/src/utils/graphTraversal.ts +32 -0
  157. package/src/utils/index.ts +19 -0
  158. package/src/utils/nodeMetrics.ts +23 -0
  159. package/src/utils/nodeSizing.ts +97 -0
  160. package/tsconfig.json +11 -0
  161. 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
+ };
@@ -0,0 +1,3 @@
1
+ export { renderGraphToSvg } from './svg';
2
+ export { defaultNodeRenderer, defaultEdgeRenderer } from './defaultRenderers';
3
+ export { escapeXml } from './utils';