@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,495 @@
1
+ import { NxGraphInput, NodeData, EdgeData, NxEdgeAttrs, EdgeType } from '@graph-render/types';
2
+
3
+ type GraphNodeTuple<TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel> = NodeData<
4
+ TNodeData,
5
+ TNodeMeta,
6
+ TNodeLabel
7
+ >;
8
+
9
+ type GraphEdgeTuple<TEdgeMeta extends Record<string, unknown>, TEdgeLabel> = EdgeData<
10
+ TEdgeMeta,
11
+ TEdgeLabel
12
+ >;
13
+
14
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ };
17
+
18
+ const isFiniteNumber = (value: unknown): value is number =>
19
+ typeof value === 'number' && Number.isFinite(value);
20
+
21
+ const sanitizeNodeId = (value: string, kind: 'node' | 'edge-endpoint'): string => {
22
+ const normalized = value.trim();
23
+ if (!normalized) {
24
+ throw new TypeError(`Graph ${kind} identifiers must be non-empty strings.`);
25
+ }
26
+
27
+ return normalized;
28
+ };
29
+
30
+ const sanitizePoint = (value: unknown): { x: number; y: number } | undefined => {
31
+ if (!isPlainObject(value) || !isFiniteNumber(value.x) || !isFiniteNumber(value.y)) {
32
+ return undefined;
33
+ }
34
+
35
+ return { x: value.x, y: value.y };
36
+ };
37
+
38
+ const sanitizeSize = (value: unknown): { width: number; height: number } | undefined => {
39
+ if (!isPlainObject(value) || !isFiniteNumber(value.width) || !isFiniteNumber(value.height)) {
40
+ return undefined;
41
+ }
42
+
43
+ return value.width > 0 && value.height > 0
44
+ ? { width: value.width, height: value.height }
45
+ : undefined;
46
+ };
47
+
48
+ const sanitizeRecord = <T extends Record<string, unknown>>(value: unknown): T | undefined => {
49
+ return isPlainObject(value) ? (value as T) : undefined;
50
+ };
51
+
52
+ const sanitizeMeasurementHints = (value: unknown): NodeData['measurementHints'] | undefined => {
53
+ if (!isPlainObject(value)) return undefined;
54
+ // FIX: was a bare cast `(value as NodeData['measurementHints'])` that let
55
+ // non-numeric values (e.g., paddingX: "8px") flow into layout arithmetic and
56
+ // produce NaN node sizes. Each field is now validated individually.
57
+ return {
58
+ label: typeof value.label === 'string' ? value.label : undefined,
59
+ paddingX:
60
+ isFiniteNumber(value.paddingX) && (value.paddingX as number) >= 0
61
+ ? (value.paddingX as number)
62
+ : undefined,
63
+ paddingY:
64
+ isFiniteNumber(value.paddingY) && (value.paddingY as number) >= 0
65
+ ? (value.paddingY as number)
66
+ : undefined,
67
+ estimatedCharWidth:
68
+ isFiniteNumber(value.estimatedCharWidth) && (value.estimatedCharWidth as number) > 0
69
+ ? (value.estimatedCharWidth as number)
70
+ : undefined,
71
+ lineHeight:
72
+ isFiniteNumber(value.lineHeight) && (value.lineHeight as number) > 0
73
+ ? (value.lineHeight as number)
74
+ : undefined,
75
+ };
76
+ };
77
+
78
+ const sanitizeNodeData = <TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel>(
79
+ id: string,
80
+ attrs: Record<string, unknown>
81
+ ): GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel> => {
82
+ const position = sanitizePoint(attrs.position);
83
+ const size = sanitizeSize(attrs.size);
84
+ const measuredSize = sanitizeSize(attrs.measuredSize);
85
+ const measurementHints = sanitizeMeasurementHints(attrs.measurementHints);
86
+
87
+ return {
88
+ id,
89
+ label: attrs.label,
90
+ position,
91
+ size,
92
+ measuredSize,
93
+ sizeMode:
94
+ attrs.sizeMode === 'fixed' || attrs.sizeMode === 'label' || attrs.sizeMode === 'measured'
95
+ ? attrs.sizeMode
96
+ : undefined,
97
+ measurementHints,
98
+ data: attrs.data,
99
+ meta: sanitizeRecord<TNodeMeta>(attrs.meta),
100
+ } as GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>;
101
+ };
102
+
103
+ const sanitizeEdgePoints = (value: unknown): EdgeData['points'] | undefined => {
104
+ if (!Array.isArray(value)) {
105
+ return undefined;
106
+ }
107
+
108
+ const points = value
109
+ .map((point) => sanitizePoint(point))
110
+ .filter((point): point is NonNullable<EdgeData['points']>[number] => point !== undefined);
111
+ return points.length >= 2 ? points : undefined;
112
+ };
113
+
114
+ const assertUniqueEdgeId = (candidate: string, usedEdgeIds: Set<string>): string => {
115
+ if (usedEdgeIds.has(candidate)) {
116
+ throw new TypeError(
117
+ `Graph edge identifiers must be unique. Duplicate edge id "${candidate}" was provided.`
118
+ );
119
+ }
120
+
121
+ usedEdgeIds.add(candidate);
122
+ return candidate;
123
+ };
124
+
125
+ const assertValidGraphInput = (graph: NxGraphInput): void => {
126
+ if (!isPlainObject(graph)) {
127
+ throw new TypeError('Graph input must be a plain object.');
128
+ }
129
+
130
+ if (!isPlainObject(graph.adj)) {
131
+ throw new TypeError('Graph input must include an adjacency map in `adj`.');
132
+ }
133
+
134
+ if (graph.nodes != null && !isPlainObject(graph.nodes)) {
135
+ throw new TypeError('Graph `nodes` must be a record of node attributes when provided.');
136
+ }
137
+
138
+ for (const [source, neighbors] of Object.entries(graph.adj)) {
139
+ if (!isPlainObject(neighbors)) {
140
+ throw new TypeError(`Adjacency entry for node "${source}" must be an object.`);
141
+ }
142
+
143
+ for (const [target, rawAttrs] of Object.entries(neighbors)) {
144
+ const attrsList = Array.isArray(rawAttrs) ? rawAttrs : [rawAttrs];
145
+ if (!attrsList.length) {
146
+ throw new TypeError(
147
+ `Adjacency entry for edge "${source}" -> "${target}" must not be an empty array.`
148
+ );
149
+ }
150
+
151
+ attrsList.forEach((attrs, index) => {
152
+ if (attrs != null && !isPlainObject(attrs)) {
153
+ throw new TypeError(
154
+ `Edge attributes for "${source}" -> "${target}" at index ${index} must be an object.`
155
+ );
156
+ }
157
+ });
158
+ }
159
+ }
160
+ };
161
+
162
+ /**
163
+ * Build node map from graph node definitions
164
+ */
165
+ const buildNodeMap = <
166
+ TNodeData,
167
+ TNodeMeta extends Record<string, unknown>,
168
+ TNodeLabel,
169
+ TEdgeMeta extends Record<string, unknown>,
170
+ TEdgeLabel,
171
+ >(
172
+ graph: NxGraphInput<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>
173
+ ): Map<string, GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>> => {
174
+ const nodeMap = new Map<string, GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>>();
175
+
176
+ if (graph.nodes) {
177
+ for (const [id, attrs] of Object.entries(graph.nodes)) {
178
+ if (attrs != null && !isPlainObject(attrs)) {
179
+ throw new TypeError(`Node attributes for "${id}" must be an object when provided.`);
180
+ }
181
+
182
+ const sanitizedId = sanitizeNodeId(id, 'node');
183
+ nodeMap.set(
184
+ sanitizedId,
185
+ sanitizeNodeData<TNodeData, TNodeMeta, TNodeLabel>(sanitizedId, attrs ?? {})
186
+ );
187
+ }
188
+ }
189
+
190
+ return nodeMap;
191
+ };
192
+
193
+ const hasExplicitNodeDefinitions = (graph: NxGraphInput): boolean => {
194
+ return Boolean(graph.nodes && Object.keys(graph.nodes).length > 0);
195
+ };
196
+
197
+ /**
198
+ * Ensure a node exists in the map, creating it if necessary
199
+ */
200
+ const ensureNodeExists = <TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel>(
201
+ nodeMap: Map<string, NodeData<TNodeData, TNodeMeta, TNodeLabel>>,
202
+ nodeId: string
203
+ ): void => {
204
+ const sanitizedNodeId = sanitizeNodeId(nodeId, 'edge-endpoint');
205
+ if (!nodeMap.has(sanitizedNodeId)) {
206
+ nodeMap.set(sanitizedNodeId, { id: sanitizedNodeId } as NodeData<
207
+ TNodeData,
208
+ TNodeMeta,
209
+ TNodeLabel
210
+ >);
211
+ }
212
+ };
213
+
214
+ const assertNodeExists = <TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel>(
215
+ nodeMap: Map<string, NodeData<TNodeData, TNodeMeta, TNodeLabel>>,
216
+ nodeId: string,
217
+ graph: NxGraphInput,
218
+ kind: 'source' | 'target'
219
+ ): void => {
220
+ const sanitizedNodeId = sanitizeNodeId(nodeId, 'edge-endpoint');
221
+
222
+ if (!hasExplicitNodeDefinitions(graph)) {
223
+ ensureNodeExists(nodeMap, sanitizedNodeId);
224
+ return;
225
+ }
226
+
227
+ if (!nodeMap.has(sanitizedNodeId)) {
228
+ throw new TypeError(
229
+ `Graph edge ${kind} "${sanitizedNodeId}" must exist in graph.nodes when explicit node definitions are provided.`
230
+ );
231
+ }
232
+ };
233
+
234
+ /**
235
+ * Normalize edge attributes to array format
236
+ */
237
+ const normalizeEdgeAttributes = <TEdgeMeta extends Record<string, unknown>, TEdgeLabel>(
238
+ rawAttrs: NxEdgeAttrs<TEdgeMeta, TEdgeLabel> | NxEdgeAttrs<TEdgeMeta, TEdgeLabel>[]
239
+ ): NxEdgeAttrs<TEdgeMeta, TEdgeLabel>[] => {
240
+ return Array.isArray(rawAttrs) ? rawAttrs : [rawAttrs];
241
+ };
242
+
243
+ /**
244
+ * Generate unique key for undirected edge deduplication
245
+ */
246
+ const createUndirectedEdgeKey = (source: string, target: string, index: number): string => {
247
+ return `${[source, target].sort().join('|')}|${index}`;
248
+ };
249
+
250
+ /**
251
+ * Generate default edge ID
252
+ */
253
+ const generateEdgeId = (source: string, target: string, index: number): string => {
254
+ return `${source}-${target}-${index}`;
255
+ };
256
+
257
+ /**
258
+ * Check if undirected edge was already processed
259
+ */
260
+ const isUndirectedEdgeSeen = (
261
+ edgeType: EdgeType,
262
+ source: string,
263
+ target: string,
264
+ index: number,
265
+ seenSet: Set<string>
266
+ ): boolean => {
267
+ if (edgeType !== EdgeType.Undirected) return false;
268
+
269
+ const key = createUndirectedEdgeKey(source, target, index);
270
+ if (seenSet.has(key)) return true;
271
+
272
+ seenSet.add(key);
273
+ return false;
274
+ };
275
+
276
+ /**
277
+ * Create edge data object from attributes
278
+ */
279
+ const createEdgeData = (
280
+ source: string,
281
+ target: string,
282
+ index: number,
283
+ attrs: NxEdgeAttrs | undefined,
284
+ defaultEdgeType: EdgeType,
285
+ usedEdgeIds: Set<string>
286
+ ): EdgeData => {
287
+ const { id, type, points, meta, ...rest } = attrs ?? {};
288
+ const edgeType = (type as EdgeType | undefined) ?? defaultEdgeType;
289
+ const baseId = sanitizeNodeId(String(id ?? generateEdgeId(source, target, index)), 'node');
290
+
291
+ return {
292
+ id: assertUniqueEdgeId(baseId, usedEdgeIds),
293
+ source,
294
+ target,
295
+ type:
296
+ edgeType === EdgeType.Directed || edgeType === EdgeType.Undirected
297
+ ? edgeType
298
+ : defaultEdgeType,
299
+ points: sanitizeEdgePoints(points),
300
+ meta: sanitizeRecord<NonNullable<EdgeData['meta']>>(meta),
301
+ ...rest,
302
+ };
303
+ };
304
+
305
+ const createTypedEdgeData = <TEdgeMeta extends Record<string, unknown>, TEdgeLabel>(
306
+ source: string,
307
+ target: string,
308
+ index: number,
309
+ attrs: NxEdgeAttrs<TEdgeMeta, TEdgeLabel> | undefined,
310
+ defaultEdgeType: EdgeType,
311
+ usedEdgeIds: Set<string>
312
+ ): GraphEdgeTuple<TEdgeMeta, TEdgeLabel> => {
313
+ const edgeData = createEdgeData(
314
+ source,
315
+ target,
316
+ index,
317
+ attrs as NxEdgeAttrs | undefined,
318
+ defaultEdgeType,
319
+ usedEdgeIds
320
+ );
321
+
322
+ return edgeData as GraphEdgeTuple<TEdgeMeta, TEdgeLabel>;
323
+ };
324
+
325
+ /**
326
+ * Process edges from adjacency list for a source node
327
+ */
328
+ const processNodeEdges = (
329
+ source: string,
330
+ neighbors: Record<string, NxEdgeAttrs | NxEdgeAttrs[]>,
331
+ defaultEdgeType: 'directed' | 'undirected',
332
+ graph: NxGraphInput,
333
+ nodeMap: Map<string, NodeData>,
334
+ undirectedSeen: Set<string>,
335
+ usedEdgeIds: Set<string>
336
+ ): EdgeData[] => {
337
+ const edges: EdgeData[] = [];
338
+
339
+ for (const [target, rawAttrs] of Object.entries(neighbors)) {
340
+ const sanitizedTarget = sanitizeNodeId(target, 'edge-endpoint');
341
+ assertNodeExists(nodeMap, sanitizedTarget, graph, 'target');
342
+
343
+ const attrsList = normalizeEdgeAttributes(rawAttrs);
344
+
345
+ attrsList.forEach((attrs, idx) => {
346
+ const edgeData = createEdgeData(
347
+ source,
348
+ sanitizedTarget,
349
+ idx,
350
+ attrs,
351
+ defaultEdgeType as EdgeType,
352
+ usedEdgeIds
353
+ );
354
+
355
+ // Skip if undirected edge already seen from other direction
356
+ if (!isUndirectedEdgeSeen(edgeData.type!, source, sanitizedTarget, idx, undirectedSeen)) {
357
+ edges.push(edgeData);
358
+ }
359
+ });
360
+ }
361
+
362
+ return edges;
363
+ };
364
+
365
+ const processTypedNodeEdges = <
366
+ TNodeData,
367
+ TNodeMeta extends Record<string, unknown>,
368
+ TNodeLabel,
369
+ TEdgeMeta extends Record<string, unknown>,
370
+ TEdgeLabel,
371
+ >(
372
+ source: string,
373
+ neighbors: Record<
374
+ string,
375
+ NxEdgeAttrs<TEdgeMeta, TEdgeLabel> | NxEdgeAttrs<TEdgeMeta, TEdgeLabel>[]
376
+ >,
377
+ defaultEdgeType: 'directed' | 'undirected',
378
+ graph: NxGraphInput<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>,
379
+ nodeMap: Map<string, GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>>,
380
+ undirectedSeen: Set<string>,
381
+ usedEdgeIds: Set<string>
382
+ ): GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[] => {
383
+ const edges: GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[] = [];
384
+
385
+ for (const [target, rawAttrs] of Object.entries(neighbors)) {
386
+ const sanitizedTarget = sanitizeNodeId(target, 'edge-endpoint');
387
+ assertNodeExists(nodeMap, sanitizedTarget, graph, 'target');
388
+
389
+ const attrsList = normalizeEdgeAttributes<TEdgeMeta, TEdgeLabel>(rawAttrs);
390
+
391
+ attrsList.forEach((attrs, idx) => {
392
+ const edgeData = createTypedEdgeData<TEdgeMeta, TEdgeLabel>(
393
+ source,
394
+ sanitizedTarget,
395
+ idx,
396
+ attrs,
397
+ defaultEdgeType as EdgeType,
398
+ usedEdgeIds
399
+ );
400
+
401
+ // Skip if undirected edge already seen from other direction
402
+ if (!isUndirectedEdgeSeen(edgeData.type!, source, sanitizedTarget, idx, undirectedSeen)) {
403
+ edges.push(edgeData);
404
+ }
405
+ });
406
+ }
407
+
408
+ return edges;
409
+ };
410
+
411
+ /**
412
+ * Parse NetworkX-style graph input to internal format
413
+ */
414
+ export const fromNxGraph = (
415
+ graph: NxGraphInput,
416
+ defaultEdgeType: EdgeType = EdgeType.Undirected
417
+ ): { nodes: NodeData[]; edges: EdgeData[] } => {
418
+ assertValidGraphInput(graph);
419
+
420
+ const nodeMap = buildNodeMap(graph);
421
+ const undirectedSeen = new Set<string>();
422
+ const usedEdgeIds = new Set<string>();
423
+ const edges: EdgeData[] = [];
424
+
425
+ for (const [source, neighbors] of Object.entries(graph.adj)) {
426
+ const sanitizedSource = sanitizeNodeId(source, 'edge-endpoint');
427
+ assertNodeExists(nodeMap, sanitizedSource, graph, 'source');
428
+
429
+ const nodeEdges = processNodeEdges(
430
+ sanitizedSource,
431
+ neighbors,
432
+ defaultEdgeType,
433
+ graph,
434
+ nodeMap,
435
+ undirectedSeen,
436
+ usedEdgeIds
437
+ );
438
+
439
+ edges.push(...nodeEdges);
440
+ }
441
+
442
+ return {
443
+ nodes: Array.from(nodeMap.values()),
444
+ edges,
445
+ };
446
+ };
447
+
448
+ export const fromTypedNxGraph = <
449
+ TNodeData = unknown,
450
+ TNodeMeta extends Record<string, unknown> = Record<string, unknown>,
451
+ TNodeLabel = unknown,
452
+ TEdgeMeta extends Record<string, unknown> = Record<string, unknown>,
453
+ TEdgeLabel = unknown,
454
+ >(
455
+ graph: NxGraphInput<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>,
456
+ defaultEdgeType: EdgeType = EdgeType.Undirected
457
+ ): {
458
+ nodes: GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>[];
459
+ edges: GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[];
460
+ } => {
461
+ assertValidGraphInput(graph as NxGraphInput);
462
+
463
+ const nodeMap = buildNodeMap<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>(graph);
464
+ const undirectedSeen = new Set<string>();
465
+ const usedEdgeIds = new Set<string>();
466
+ const edges: GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[] = [];
467
+
468
+ for (const [source, neighbors] of Object.entries(graph.adj)) {
469
+ const sanitizedSource = sanitizeNodeId(source, 'edge-endpoint');
470
+ assertNodeExists(nodeMap, sanitizedSource, graph, 'source');
471
+
472
+ const nodeEdges = processTypedNodeEdges<
473
+ TNodeData,
474
+ TNodeMeta,
475
+ TNodeLabel,
476
+ TEdgeMeta,
477
+ TEdgeLabel
478
+ >(
479
+ sanitizedSource,
480
+ neighbors,
481
+ defaultEdgeType,
482
+ graph,
483
+ nodeMap,
484
+ undirectedSeen,
485
+ usedEdgeIds
486
+ );
487
+
488
+ edges.push(...nodeEdges);
489
+ }
490
+
491
+ return {
492
+ nodes: Array.from(nodeMap.values()),
493
+ edges,
494
+ };
495
+ };
@@ -0,0 +1,32 @@
1
+ import { PositionedEdge } from '@graph-render/types';
2
+
3
+ /**
4
+ * Group edges by their target node
5
+ */
6
+ export const groupEdgesByTarget = (edges: PositionedEdge[]): Map<string, PositionedEdge[]> => {
7
+ const map = new Map<string, PositionedEdge[]>();
8
+ edges.forEach((edge) => {
9
+ const arr = map.get(edge.target) ?? [];
10
+ arr.push(edge);
11
+ map.set(edge.target, arr);
12
+ });
13
+ return map;
14
+ };
15
+
16
+ /**
17
+ * Sort edges by their source node position (y first, then x)
18
+ */
19
+ export const sortEdgesBySourcePosition = (
20
+ edges: PositionedEdge[],
21
+ nodePositions: Map<string, { x: number; y: number }>
22
+ ): PositionedEdge[] => {
23
+ return [...edges].sort((a, b) => {
24
+ const pa = nodePositions.get(a.source);
25
+ const pb = nodePositions.get(b.source);
26
+ if (pa && pb) {
27
+ if (pa.y !== pb.y) return pa.y - pb.y;
28
+ return pa.x - pb.x;
29
+ }
30
+ return 0;
31
+ });
32
+ };
@@ -0,0 +1,19 @@
1
+ export { fromNxGraph, fromTypedNxGraph } from './graphParser';
2
+ export {
3
+ DEFAULT_THEME,
4
+ DEFAULT_NODE_SIZE,
5
+ DEFAULT_NODE_GAP,
6
+ DEFAULT_PADDING,
7
+ DEFAULT_NODE_WIDTH,
8
+ DEFAULT_NODE_HEIGHT,
9
+ DEFAULT_NODE_RADIUS,
10
+ DEFAULT_NODE_FILL,
11
+ DEFAULT_NODE_STROKE,
12
+ DEFAULT_TEXT_FILL,
13
+ DEFAULT_TEXT_SIZE,
14
+ } from './constants';
15
+ export { groupEdgesByTarget, sortEdgesBySourcePosition } from './graphTraversal';
16
+ export { applyNodeSizing } from './nodeSizing';
17
+ export { getMaxNodeDimensions, getMaxNodeHeight, getMaxNodeWidth } from './nodeMetrics';
18
+ export { normalizeGraphConfig } from './config';
19
+ export type { NormalizedGraphConfig } from './config';
@@ -0,0 +1,23 @@
1
+ import { NodeData } from '@graph-render/types';
2
+ import { DEFAULT_NODE_SIZE } from './constants';
3
+
4
+ export const getMaxNodeWidth = (nodes: NodeData[]): number => {
5
+ return nodes.reduce(
6
+ (max, node) => Math.max(max, node.size?.width ?? DEFAULT_NODE_SIZE.width),
7
+ 0
8
+ );
9
+ };
10
+
11
+ export const getMaxNodeHeight = (nodes: NodeData[]): number => {
12
+ return nodes.reduce(
13
+ (max, node) => Math.max(max, node.size?.height ?? DEFAULT_NODE_SIZE.height),
14
+ 0
15
+ );
16
+ };
17
+
18
+ export const getMaxNodeDimensions = (
19
+ nodes: NodeData[]
20
+ ): { maxWidth: number; maxHeight: number } => ({
21
+ maxWidth: getMaxNodeWidth(nodes),
22
+ maxHeight: getMaxNodeHeight(nodes),
23
+ });
@@ -0,0 +1,97 @@
1
+ import { LayoutOptions, NodeData, Size } from '@graph-render/types';
2
+ import { DEFAULT_NODE_SIZE } from './constants';
3
+
4
+ const DEFAULT_PADDING_X = 18;
5
+ const DEFAULT_PADDING_Y = 12;
6
+ const DEFAULT_CHAR_WIDTH = 8;
7
+ const DEFAULT_LINE_HEIGHT = 18;
8
+ const MAX_MEASUREMENT_TEXT_LENGTH = 4_000;
9
+ const MAX_MEASUREMENT_LINES = 200;
10
+ const MAX_MEASUREMENT_CHARS_PER_LINE = 400;
11
+
12
+ const clampSize = (value: Size, fallback: Size): Size => ({
13
+ width: Number.isFinite(value.width) && value.width > 0 ? value.width : fallback.width,
14
+ height: Number.isFinite(value.height) && value.height > 0 ? value.height : fallback.height,
15
+ });
16
+
17
+ const getNodeLabel = (node: NodeData): string => {
18
+ if (typeof node.measurementHints?.label === 'string') {
19
+ return node.measurementHints.label;
20
+ }
21
+
22
+ if (typeof node.label === 'string' || typeof node.label === 'number') {
23
+ return String(node.label);
24
+ }
25
+
26
+ return node.id;
27
+ };
28
+
29
+ const getMeasuredLines = (label: string): string[] => {
30
+ const truncatedLabel = label.slice(0, MAX_MEASUREMENT_TEXT_LENGTH);
31
+ const rawLines = truncatedLabel.split(/\r?\n/);
32
+ const measuredLines: string[] = [];
33
+
34
+ for (let index = 0; index < rawLines.length && measuredLines.length < MAX_MEASUREMENT_LINES; index += 1) {
35
+ const codePoints = Array.from(rawLines[index]);
36
+ if (!codePoints.length) {
37
+ continue;
38
+ }
39
+
40
+ measuredLines.push(codePoints.slice(0, MAX_MEASUREMENT_CHARS_PER_LINE).join(''));
41
+ }
42
+
43
+ return measuredLines.length ? measuredLines : [''];
44
+ };
45
+
46
+ const estimateLabelSize = (node: NodeData, options: LayoutOptions): Size => {
47
+ const label = getNodeLabel(node);
48
+ const lines = getMeasuredLines(label);
49
+ const paddingX =
50
+ node.measurementHints?.paddingX ?? options.labelMeasurementPaddingX ?? DEFAULT_PADDING_X;
51
+ const paddingY =
52
+ node.measurementHints?.paddingY ?? options.labelMeasurementPaddingY ?? DEFAULT_PADDING_Y;
53
+ const charWidth =
54
+ node.measurementHints?.estimatedCharWidth ??
55
+ options.labelMeasurementCharWidth ??
56
+ DEFAULT_CHAR_WIDTH;
57
+ const lineHeight =
58
+ node.measurementHints?.lineHeight ?? options.labelMeasurementLineHeight ?? DEFAULT_LINE_HEIGHT;
59
+ let maxChars = 1;
60
+ for (const line of lines) {
61
+ const lineLength = Array.from(line).length;
62
+ if (lineLength > maxChars) {
63
+ maxChars = lineLength;
64
+ }
65
+ }
66
+ const lineCount = Math.max(1, lines.length);
67
+
68
+ return {
69
+ width: Math.max(DEFAULT_NODE_SIZE.width, Math.ceil(maxChars * charWidth + paddingX * 2)),
70
+ height: Math.max(DEFAULT_NODE_SIZE.height, Math.ceil(lineCount * lineHeight + paddingY * 2)),
71
+ };
72
+ };
73
+
74
+ const getResolvedSize = (node: NodeData, options: LayoutOptions): Size => {
75
+ const mode = node.sizeMode ?? options.nodeSizing ?? 'fixed';
76
+ const fixedSize = options.fixedNodeSize ?? DEFAULT_NODE_SIZE;
77
+ const explicitSize = node.size ? clampSize(node.size, fixedSize) : null;
78
+ const measuredSize = node.measuredSize ? clampSize(node.measuredSize, fixedSize) : null;
79
+ const estimatedSize = estimateLabelSize(node, options);
80
+
81
+ if (mode === 'measured') {
82
+ return measuredSize ?? explicitSize ?? estimatedSize;
83
+ }
84
+
85
+ if (mode === 'label') {
86
+ return explicitSize ?? estimatedSize;
87
+ }
88
+
89
+ return explicitSize ?? fixedSize;
90
+ };
91
+
92
+ export const applyNodeSizing = (nodes: NodeData[], options: LayoutOptions): NodeData[] => {
93
+ return nodes.map((node) => ({
94
+ ...node,
95
+ size: getResolvedSize(node, options),
96
+ }));
97
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "outDir": "./dist",
6
+ "rootDir": "./src"
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules", "dist"],
10
+ "references": [{ "path": "../types" }]
11
+ }