@statelyai/graph 0.11.0 → 0.12.0

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/README.md CHANGED
@@ -12,14 +12,16 @@ npm install @statelyai/graph
12
12
 
13
13
  Optional peers are only needed for specific adapters:
14
14
 
15
- | Package | Needed for |
16
- | --- | --- |
15
+ <!-- optional peer dependencies derived from package.json#peerDependencies -->
16
+
17
+ | Package | Needed for |
18
+ | ----------------- | --------------------------------------------------- |
17
19
  | `fast-xml-parser` | `@statelyai/graph/gexf`, `@statelyai/graph/graphml` |
18
- | `dotparser` | `@statelyai/graph/dot` parsing |
19
- | `cytoscape` | Cytoscape integration tests and consumer typing |
20
- | `d3-force` | D3 force integration tests and consumer typing |
21
- | `elkjs` | `@statelyai/graph/elk` |
22
- | `zod` | `@statelyai/graph/schemas` |
20
+ | `dotparser` | `@statelyai/graph/dot` parsing |
21
+ | `cytoscape` | Cytoscape integration tests and consumer typing |
22
+ | `d3-force` | D3 force integration tests and consumer typing |
23
+ | `elkjs` | `@statelyai/graph/elk` |
24
+ | `zod` | `@statelyai/graph/schemas` |
23
25
 
24
26
  ## Highlights
25
27
 
@@ -37,7 +39,12 @@ Optional peers are only needed for specific adapters:
37
39
  Graphs are plain JSON-serializable objects. All operations are standalone functions — no classes, no DOM, no rendering engine.
38
40
 
39
41
  ```ts
40
- import { createGraph, addNode, addEdge, getShortestPath } from '@statelyai/graph';
42
+ import {
43
+ createGraph,
44
+ addNode,
45
+ addEdge,
46
+ getShortestPath,
47
+ } from '@statelyai/graph';
41
48
 
42
49
  const graph = createGraph({
43
50
  nodes: [
@@ -64,12 +71,17 @@ const path = getShortestPath(graph, { from: 'a', to: 'c' });
64
71
  Look up, add, delete, and update nodes and edges. Query neighbors, predecessors, successors, degree, and more.
65
72
 
66
73
  ```ts
67
- import { getNode, deleteNode, getNeighbors, getSources } from '@statelyai/graph';
74
+ import {
75
+ getNode,
76
+ deleteNode,
77
+ getNeighbors,
78
+ getSources,
79
+ } from '@statelyai/graph';
68
80
 
69
- const node = getNode(graph, 'a'); // lookup by id
70
- deleteNode(graph, 'd'); // removes node + connected edges
81
+ const node = getNode(graph, 'a'); // lookup by id
82
+ deleteNode(graph, 'd'); // removes node + connected edges
71
83
  const neighbors = getNeighbors(graph, 'a'); // adjacent nodes
72
- const roots = getSources(graph); // nodes with no incoming edges
84
+ const roots = getSources(graph); // nodes with no incoming edges
73
85
  ```
74
86
 
75
87
  Batch operations (`addEntities`, `deleteEntities`, `updateEntities`) let you apply multiple changes at once.
@@ -90,14 +102,14 @@ const graph = createGraph({
90
102
  { id: 'c' },
91
103
  ],
92
104
  edges: [
93
- { id: 'e1', sourceId: 'a', targetId: 'b' }, // resolves to a -> b1
105
+ { id: 'e1', sourceId: 'a', targetId: 'b' }, // resolves to a -> b1
94
106
  { id: 'e2', sourceId: 'b1', targetId: 'b2' },
95
- { id: 'e3', sourceId: 'b', targetId: 'c' }, // expands from all leaves of b
107
+ { id: 'e3', sourceId: 'b', targetId: 'c' }, // expands from all leaves of b
96
108
  ],
97
109
  });
98
110
 
99
111
  const children = getChildren(graph, 'b'); // [b1, b2]
100
- const flat = flatten(graph); // only leaf nodes, edges resolved
112
+ const flat = flatten(graph); // only leaf nodes, edges resolved
101
113
  ```
102
114
 
103
115
  ## Ports
@@ -133,33 +145,68 @@ getPorts(graph, 'fetch'); // [{ name: 'result', ... }]
133
145
  getEdgesByPort(graph, 'render', 'input'); // [e1]
134
146
  ```
135
147
 
148
+ ## Schema Validation
149
+
150
+ <!-- validation helpers exported from src/schemas.ts -->
151
+
152
+ Use the `@statelyai/graph/schemas` subpath when you want runtime validation or JSON Schema generation.
153
+
154
+ ```ts
155
+ import { GraphSchema, getGraphIssues, isGraph } from '@statelyai/graph/schemas';
156
+
157
+ const unknownValue: unknown = JSON.parse(input);
158
+
159
+ if (isGraph(unknownValue)) {
160
+ // fully typed Graph
161
+ } else {
162
+ console.error(getGraphIssues(unknownValue));
163
+ }
164
+
165
+ const parsed = GraphSchema.parse(unknownValue);
166
+ ```
167
+
136
168
  ## Algorithms
137
169
 
138
- Includes traversal (BFS, DFS), pathfinding (shortest path, simple paths, all-pairs shortest paths), centrality/link analysis (degree, closeness, betweenness, PageRank, HITS, eigenvector), community detection (label propagation, Girvan-Newman, greedy modularity, modularity scoring), cycle detection, connected/strongly-connected components, bridges, articulation points, biconnected components, isomorphism, topological sort, minimum spanning tree, and more. Many algorithms have lazy generator variants (`gen*`) for early exit.
170
+ <!-- algorithm functions exported from src/algorithms.ts -->
171
+
172
+ Includes traversal (BFS, DFS, preorder/postorder), pathfinding (shortest path, simple paths, all-pairs shortest paths, A*), centrality/link analysis (degree, closeness, betweenness, PageRank, HITS, eigenvector), community detection (label propagation, Girvan-Newman, greedy modularity, modularity scoring), cycle detection, connected/strongly-connected components, bridges, articulation points, biconnected components, isomorphism, topological sort, minimum spanning tree, and more. Many algorithms have lazy generator variants (`gen*`) for early exit.
139
173
 
140
174
  ```ts
141
175
  import {
142
- bfs, dfs, hasPath, isAcyclic,
143
- getShortestPath, getCycles, getTopologicalSort,
144
- getConnectedComponents, getMinimumSpanningTree,
145
- getPageRank, getLabelPropagationCommunities,
146
- genGirvanNewmanCommunities, getBridges, isIsomorphic,
176
+ bfs,
177
+ dfs,
178
+ hasPath,
179
+ isAcyclic,
180
+ getShortestPath,
181
+ getCycles,
182
+ getTopologicalSort,
183
+ getConnectedComponents,
184
+ getMinimumSpanningTree,
185
+ getPageRank,
186
+ getLabelPropagationCommunities,
187
+ genGirvanNewmanCommunities,
188
+ getBridges,
189
+ isIsomorphic,
147
190
  } from '@statelyai/graph';
148
191
 
149
- for (const node of bfs(graph, 'a')) { /* breadth-first */ }
150
- for (const node of dfs(graph, 'a')) { /* depth-first */ }
151
-
152
- hasPath(graph, 'a', 'c'); // reachability
153
- isAcyclic(graph); // cycle check
154
- getShortestPath(graph, { from: 'a', to: 'c' }); // single shortest path
155
- getTopologicalSort(graph); // topological order (or null)
156
- getConnectedComponents(graph); // connected components
157
- getMinimumSpanningTree(graph, { weight: e => e.data?.weight ?? 1 }); // MST
158
- getPageRank(graph); // link analysis scores
159
- getLabelPropagationCommunities(graph); // community detection
160
- [...genGirvanNewmanCommunities(graph)]; // lazy community splits
161
- getBridges(graph); // bridge edges
162
- isIsomorphic(graph, otherGraph); // structural equivalence
192
+ for (const node of bfs(graph, 'a')) {
193
+ /* breadth-first */
194
+ }
195
+ for (const node of dfs(graph, 'a')) {
196
+ /* depth-first */
197
+ }
198
+
199
+ hasPath(graph, 'a', 'c'); // reachability
200
+ isAcyclic(graph); // cycle check
201
+ getShortestPath(graph, { from: 'a', to: 'c' }); // single shortest path
202
+ getTopologicalSort(graph); // topological order (or null)
203
+ getConnectedComponents(graph); // connected components
204
+ getMinimumSpanningTree(graph, { weight: (e) => e.data?.weight ?? 1 }); // MST
205
+ getPageRank(graph); // link analysis scores
206
+ getLabelPropagationCommunities(graph); // community detection
207
+ [...genGirvanNewmanCommunities(graph)]; // lazy community splits
208
+ getBridges(graph); // bridge edges
209
+ isIsomorphic(graph, otherGraph); // structural equivalence
163
210
  ```
164
211
 
165
212
  ## Diff & Walks
@@ -197,13 +244,15 @@ import { fromGEXF } from '@statelyai/graph/gexf';
197
244
  import { toCytoscapeJSON } from '@statelyai/graph/cytoscape';
198
245
  import { toD3Graph } from '@statelyai/graph/d3';
199
246
 
200
- const dot = toDOT(graph); // Graphviz DOT
201
- const cytoData = toCytoscapeJSON(graph); // Cytoscape.js JSON
202
- const d3Data = toD3Graph(graph); // D3.js { nodes, links }
203
- const imported = fromGEXF(gexfXmlString); // GEXF (Gephi)
247
+ const dot = toDOT(graph); // Graphviz DOT
248
+ const cytoData = toCytoscapeJSON(graph); // Cytoscape.js JSON
249
+ const d3Data = toD3Graph(graph); // D3.js { nodes, links }
250
+ const imported = fromGEXF(gexfXmlString); // GEXF (Gephi)
204
251
  ```
205
252
 
206
- **Supported formats:** Cytoscape.js JSON, D3.js JSON, JSON Graph Format, GEXF, GraphML, GML, TGF, DOT, Mermaid (flowchart, state, sequence, class, ER, mindmap, block), adjacency list, and edge list.
253
+ <!-- supported format adapters derived from src/formats/* subdirectories -->
254
+
255
+ **Supported formats:** Cytoscape.js JSON, D3.js JSON, JSON Graph Format, GEXF, GraphML, GML, TGF, DOT, Mermaid (flowchart, state, sequence, class, ER, mindmap, block, Ishikawa), ELK, xyflow, adjacency list, and edge list.
207
256
 
208
257
  Each bidirectional format also has a converter object:
209
258
 
@@ -214,23 +263,58 @@ const cyto = cytoscapeConverter.to(graph);
214
263
  const back = cytoscapeConverter.from(cyto);
215
264
  ```
216
265
 
266
+ ## Format Support
267
+
268
+ <!-- format support matrix derived from src/formats/support.ts -->
269
+
270
+ | Format | Hierarchy | Ports | Visual | Round-trip | Notes |
271
+ | ------------------- | --------- | ------- | ------- | ---------- | -------------------------------------------------------------------------- |
272
+ | `adjacency-list` | none | none | none | partial | Connectivity only; edge metadata is lost. |
273
+ | `cytoscape` | full | full | partial | partial | Ports round-trip through element data. |
274
+ | `d3` | none | full | partial | partial | Ports round-trip through node/link objects. |
275
+ | `dot` | partial | partial | partial | partial | Edge port ids round-trip, but `:port:compass` mapping is still incomplete. |
276
+ | `edge-list` | none | none | none | partial | Endpoints only. |
277
+ | `elk` | full | full | full | partial | Best for layout exchange. |
278
+ | `gexf` | full | full | partial | partial | Ports round-trip via custom attributes. |
279
+ | `gml` | full | full | partial | partial | Ports round-trip through JSON-stringified metadata. |
280
+ | `graphml` | full | full | partial | partial | Ports round-trip through `<data>` fields. |
281
+ | `jgf` | full | full | none | partial | Ports round-trip through metadata. |
282
+ | `tgf` | none | none | none | partial | Minimal ids and labels only. |
283
+ | `xyflow` | none | full | full | partial | Ports map directly to handles. |
284
+ | `mermaid/block` | partial | none | partial | partial | Syntax-driven, not port-aware. |
285
+ | `mermaid/class` | none | none | none | partial | Class syntax is stored conservatively. |
286
+ | `mermaid/er` | none | none | none | partial | Focuses on entities and cardinality. |
287
+ | `mermaid/flowchart` | partial | none | partial | partial | `linkStyle` indices are fragile. |
288
+ | `mermaid/ishikawa` | full | none | none | partial | Preserves hierarchy, not fishbone layout. |
289
+ | `mermaid/mindmap` | full | none | partial | partial | Icon syntax is not fully re-emitted. |
290
+ | `mermaid/sequence` | partial | none | none | partial | Actor links and menu syntax are incomplete. |
291
+ | `mermaid/state` | full | none | partial | partial | State notes are still lossy. |
292
+
217
293
  Some formats have optional peer dependencies: `fast-xml-parser` (GEXF, GraphML) and `dotparser` (DOT). All other formats are dependency-free.
218
294
 
219
295
  Format-specific docs live alongside the source:
220
296
 
297
+ <!-- format README files under src/formats/*/README.md -->
298
+
299
+ - [Adjacency list](./src/formats/adjacency-list/README.md)
300
+ - [Cytoscape](./src/formats/cytoscape/README.md)
301
+ - [D3](./src/formats/d3/README.md)
221
302
  - [DOT](./src/formats/dot/README.md)
222
- - [GraphML](./src/formats/graphml/README.md)
303
+ - [Edge list](./src/formats/edge-list/README.md)
304
+ - [ELK](./src/formats/elk/README.md)
223
305
  - [GEXF](./src/formats/gexf/README.md)
224
306
  - [GML](./src/formats/gml/README.md)
307
+ - [GraphML](./src/formats/graphml/README.md)
225
308
  - [JGF](./src/formats/jgf/README.md)
226
- - [TGF](./src/formats/tgf/README.md)
227
- - [Cytoscape](./src/formats/cytoscape/README.md)
228
- - [D3](./src/formats/d3/README.md)
229
309
  - [Mermaid](./src/formats/mermaid/README.md)
310
+ - [TGF](./src/formats/tgf/README.md)
311
+ - [xyflow](./src/formats/xyflow/README.md)
230
312
  - [Converter helpers](./src/formats/converter/README.md)
231
313
 
232
314
  ## Examples
233
315
 
316
+ <!-- runnable example files under examples/ -->
317
+
234
318
  The repo includes runnable examples under [`examples/`](./examples):
235
319
 
236
320
  - [Flow-based math](./examples/flow-based-math.ts) shows ports, topological ordering, and value propagation.
@@ -238,6 +322,8 @@ The repo includes runnable examples under [`examples/`](./examples):
238
322
 
239
323
  ## Development
240
324
 
325
+ <!-- dev commands from package.json#scripts -->
326
+
241
327
  ```bash
242
328
  pnpm install
243
329
  pnpm verify
@@ -21,14 +21,14 @@ const FORMAT_SUPPORT_MATRIX = [
21
21
  features: {
22
22
  directed: "full",
23
23
  undirected: "full",
24
- hierarchy: "partial",
25
- ports: "none",
24
+ hierarchy: "full",
25
+ ports: "full",
26
26
  visual: "partial",
27
27
  style: "partial",
28
28
  weight: "full",
29
29
  roundTrip: "partial"
30
30
  },
31
- notes: ["Uses Cytoscape JSON element data with partial layout/style fidelity."]
31
+ notes: ["Uses Cytoscape JSON element data with partial layout/style fidelity.", "Ports round-trip through element data as `ports`, `sourcePort`, and `targetPort`."]
32
32
  },
33
33
  {
34
34
  id: "d3",
@@ -37,13 +37,13 @@ const FORMAT_SUPPORT_MATRIX = [
37
37
  directed: "full",
38
38
  undirected: "full",
39
39
  hierarchy: "none",
40
- ports: "none",
40
+ ports: "full",
41
41
  visual: "partial",
42
42
  style: "none",
43
43
  weight: "full",
44
44
  roundTrip: "partial"
45
45
  },
46
- notes: ["Targets force-graph structures, not compound graph metadata."]
46
+ notes: ["Targets force-graph structures, not compound graph metadata.", "Ports round-trip through node/link objects."]
47
47
  },
48
48
  {
49
49
  id: "dot",
@@ -58,7 +58,7 @@ const FORMAT_SUPPORT_MATRIX = [
58
58
  weight: "none",
59
59
  roundTrip: "partial"
60
60
  },
61
- notes: ["Port syntax (`:port:compass`) is not fully mapped.", "HTML labels and layout hints beyond `rankdir` are lossy."]
61
+ notes: ["Edge port ids round-trip, but compass points and node port definitions are not mapped.", "HTML labels and layout hints beyond `rankdir` are lossy."]
62
62
  },
63
63
  {
64
64
  id: "edge-list",
@@ -96,14 +96,14 @@ const FORMAT_SUPPORT_MATRIX = [
96
96
  features: {
97
97
  directed: "full",
98
98
  undirected: "full",
99
- hierarchy: "none",
100
- ports: "none",
99
+ hierarchy: "full",
100
+ ports: "full",
101
101
  visual: "partial",
102
102
  style: "partial",
103
103
  weight: "full",
104
104
  roundTrip: "partial"
105
105
  },
106
- notes: ["Attribute and viz extensions round-trip partially."]
106
+ notes: ["Attribute and viz extensions round-trip partially.", "Ports round-trip via custom node/edge attributes."]
107
107
  },
108
108
  {
109
109
  id: "gml",
@@ -111,14 +111,14 @@ const FORMAT_SUPPORT_MATRIX = [
111
111
  features: {
112
112
  directed: "full",
113
113
  undirected: "full",
114
- hierarchy: "none",
115
- ports: "none",
114
+ hierarchy: "full",
115
+ ports: "full",
116
116
  visual: "partial",
117
117
  style: "partial",
118
118
  weight: "full",
119
119
  roundTrip: "partial"
120
120
  },
121
- notes: ["GML support focuses on common graph/node/edge attributes."]
121
+ notes: ["GML support focuses on common graph/node/edge attributes.", "Ports round-trip through JSON-stringified node metadata and edge fields."]
122
122
  },
123
123
  {
124
124
  id: "graphml",
@@ -126,14 +126,14 @@ const FORMAT_SUPPORT_MATRIX = [
126
126
  features: {
127
127
  directed: "full",
128
128
  undirected: "full",
129
- hierarchy: "none",
130
- ports: "none",
129
+ hierarchy: "full",
130
+ ports: "full",
131
131
  visual: "partial",
132
132
  style: "partial",
133
133
  weight: "full",
134
134
  roundTrip: "partial"
135
135
  },
136
- notes: ["GraphML attribute fidelity is good, but not every extension is represented."]
136
+ notes: ["GraphML attribute fidelity is good, but not every extension is represented.", "Ports round-trip through node and edge `<data>` fields."]
137
137
  },
138
138
  {
139
139
  id: "jgf",
@@ -141,14 +141,14 @@ const FORMAT_SUPPORT_MATRIX = [
141
141
  features: {
142
142
  directed: "full",
143
143
  undirected: "full",
144
- hierarchy: "none",
145
- ports: "none",
144
+ hierarchy: "full",
145
+ ports: "full",
146
146
  visual: "none",
147
147
  style: "none",
148
148
  weight: "full",
149
149
  roundTrip: "partial"
150
150
  },
151
- notes: ["JGF preserves core graph structure and data, but not layout primitives."]
151
+ notes: ["JGF preserves core graph structure and data, but not layout primitives.", "Ports round-trip through node and edge metadata."]
152
152
  },
153
153
  {
154
154
  id: "tgf",
@@ -240,6 +240,21 @@ const FORMAT_SUPPORT_MATRIX = [
240
240
  },
241
241
  notes: ["Index-based `linkStyle` metadata is fragile after graph mutation.", "Mermaid init directives are not fully preserved."]
242
242
  },
243
+ {
244
+ id: "mermaid/ishikawa",
245
+ importPath: "@statelyai/graph/mermaid",
246
+ features: {
247
+ directed: "full",
248
+ undirected: "none",
249
+ hierarchy: "full",
250
+ ports: "none",
251
+ visual: "none",
252
+ style: "none",
253
+ weight: "none",
254
+ roundTrip: "partial"
255
+ },
256
+ notes: ["Indentation is preserved as hierarchy; renderer-specific fishbone layout is not represented."]
257
+ },
243
258
  {
244
259
  id: "mermaid/mindmap",
245
260
  importPath: "@statelyai/graph/mermaid",
@@ -38,6 +38,7 @@ function toCytoscapeJSON(graph) {
38
38
  if (n.height !== void 0) data.height = n.height;
39
39
  if (n.shape) data.shape = n.shape;
40
40
  if (n.color) data.color = n.color;
41
+ if (n.ports !== void 0) data.ports = n.ports;
41
42
  const node = { data };
42
43
  if (n.x !== void 0 && n.y !== void 0) node.position = {
43
44
  x: n.x,
@@ -54,6 +55,8 @@ function toCytoscapeJSON(graph) {
54
55
  if (e.label) data.label = e.label;
55
56
  if (e.data !== void 0) data.edgeData = e.data;
56
57
  if (e.color) data.color = e.color;
58
+ if (e.sourcePort !== void 0) data.sourcePort = e.sourcePort;
59
+ if (e.targetPort !== void 0) data.targetPort = e.targetPort;
57
60
  return { data };
58
61
  })
59
62
  }
@@ -99,7 +102,8 @@ function fromCytoscapeJSON(cyto) {
99
102
  ...n.data.width !== void 0 && { width: n.data.width },
100
103
  ...n.data.height !== void 0 && { height: n.data.height },
101
104
  ...n.data.shape && { shape: n.data.shape },
102
- ...n.data.color && { color: n.data.color }
105
+ ...n.data.color && { color: n.data.color },
106
+ ...n.data.ports !== void 0 && { ports: n.data.ports }
103
107
  })),
104
108
  edges: cyto.elements.edges.map((e, i) => ({
105
109
  type: "edge",
@@ -108,7 +112,9 @@ function fromCytoscapeJSON(cyto) {
108
112
  targetId: e.data.target,
109
113
  label: e.data.label ?? "",
110
114
  data: e.data.edgeData,
111
- ...e.data.color && { color: e.data.color }
115
+ ...e.data.color && { color: e.data.color },
116
+ ...e.data.sourcePort !== void 0 && { sourcePort: e.data.sourcePort },
117
+ ...e.data.targetPort !== void 0 && { targetPort: e.data.targetPort }
112
118
  }))
113
119
  };
114
120
  }
@@ -28,6 +28,7 @@ function toD3Graph(graph) {
28
28
  if (n.y !== void 0) node.y = n.y;
29
29
  if (n.color) node.color = n.color;
30
30
  if (n.shape) node.shape = n.shape;
31
+ if (n.ports !== void 0) node.ports = n.ports;
31
32
  return node;
32
33
  }),
33
34
  links: graph.edges.map((e) => {
@@ -39,6 +40,8 @@ function toD3Graph(graph) {
39
40
  if (e.label) link.label = e.label;
40
41
  if (e.data !== void 0) link.data = e.data;
41
42
  if (e.color) link.color = e.color;
43
+ if (e.sourcePort !== void 0) link.sourcePort = e.sourcePort;
44
+ if (e.targetPort !== void 0) link.targetPort = e.targetPort;
42
45
  return link;
43
46
  })
44
47
  };
@@ -75,7 +78,8 @@ function fromD3Graph(d3) {
75
78
  ...n.x !== void 0 && { x: n.x },
76
79
  ...n.y !== void 0 && { y: n.y },
77
80
  ...n.color && { color: n.color },
78
- ...n.shape && { shape: n.shape }
81
+ ...n.shape && { shape: n.shape },
82
+ ...n.ports !== void 0 && { ports: n.ports }
79
83
  })),
80
84
  edges: d3.links.map((l, i) => ({
81
85
  type: "edge",
@@ -84,7 +88,9 @@ function fromD3Graph(d3) {
84
88
  targetId: typeof l.target === "string" ? l.target : l.target.id,
85
89
  label: l.label ?? "",
86
90
  data: l.data,
87
- ...l.color && { color: l.color }
91
+ ...l.color && { color: l.color },
92
+ ...l.sourcePort !== void 0 && { sourcePort: l.sourcePort },
93
+ ...l.targetPort !== void 0 && { targetPort: l.targetPort }
88
94
  }))
89
95
  };
90
96
  }
@@ -11,6 +11,9 @@ function escapeId(id) {
11
11
  function escapeLabel(label) {
12
12
  return label.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
13
13
  }
14
+ function formatEndpoint(id, port) {
15
+ return `${escapeId(id)}${port ? `:${escapeId(port)}` : ""}`;
16
+ }
14
17
  const DIRECTION_TO_RANKDIR = {
15
18
  down: "TB",
16
19
  up: "BT",
@@ -72,7 +75,7 @@ function toDOT(graph) {
72
75
  if (edge.label) attrs.push(`label="${escapeLabel(edge.label)}"`);
73
76
  if (edge.color) attrs.push(`color="${escapeLabel(edge.color)}"`);
74
77
  const attrStr = attrs.length > 0 ? ` [${attrs.join(", ")}]` : "";
75
- lines.push(` ${escapeId(edge.sourceId)} ${edgeOp} ${escapeId(edge.targetId)}${attrStr};`);
78
+ lines.push(` ${formatEndpoint(edge.sourceId, edge.sourcePort)} ${edgeOp} ${formatEndpoint(edge.targetId, edge.targetPort)}${attrStr};`);
76
79
  }
77
80
  lines.push("}");
78
81
  return lines.join("\n");
@@ -95,6 +98,10 @@ const DOT_TO_SHAPE = {
95
98
  cylinder: "cylinder",
96
99
  parallelogram: "parallelogram"
97
100
  };
101
+ function getPortId(nodeId) {
102
+ const port = nodeId.port;
103
+ return typeof port?.id === "string" ? port.id : void 0;
104
+ }
98
105
  function attrsToMap(attrList) {
99
106
  const map = {};
100
107
  for (const a of attrList) map[a.id] = String(a.eq);
@@ -209,24 +216,29 @@ function fromDOT(dot) {
209
216
  const endpointGroups = [];
210
217
  for (const item of stmt.edge_list) if (item.type === "node_id") {
211
218
  ensureNode(item.id, parentId, nd);
212
- endpointGroups.push([item.id]);
219
+ endpointGroups.push([{
220
+ id: item.id,
221
+ ...getPortId(item) && { port: getPortId(item) }
222
+ }]);
213
223
  } else if (item.type === "subgraph") {
214
224
  walkChildren(item.children, parentId, nd, ed);
215
225
  const subNodeIds = getNodeIdsFromSubgraph(item.children);
216
226
  for (const subNodeId of subNodeIds) ensureNode(subNodeId, parentId, nd);
217
- if (subNodeIds.length > 0) endpointGroups.push(subNodeIds);
227
+ if (subNodeIds.length > 0) endpointGroups.push(subNodeIds.map((id) => ({ id })));
218
228
  }
219
229
  for (let i = 0; i < endpointGroups.length - 1; i++) {
220
230
  const left = endpointGroups[i];
221
231
  const right = endpointGroups[i + 1];
222
- for (const sourceId of left) for (const targetId of right) {
232
+ for (const source of left) for (const target of right) {
223
233
  const edge = {
224
234
  type: "edge",
225
235
  id: `e${edgeIdx++}`,
226
- sourceId,
227
- targetId,
236
+ sourceId: source.id,
237
+ targetId: target.id,
228
238
  label: mergedEdgeAttrs["label"] ?? "",
229
239
  data: void 0,
240
+ ...source.port && { sourcePort: source.port },
241
+ ...target.port && { targetPort: target.port },
230
242
  ...mergedEdgeAttrs["color"] && { color: mergedEdgeAttrs["color"] }
231
243
  };
232
244
  edges.push(edge);
@@ -23,13 +23,30 @@ function toGEXF(graph) {
23
23
  "@_id": "a_shape",
24
24
  "@_title": "shape",
25
25
  "@_type": "string"
26
+ },
27
+ {
28
+ "@_id": "a_ports",
29
+ "@_title": "ports",
30
+ "@_type": "string"
31
+ }
32
+ ];
33
+ const edgeAttrs = [
34
+ {
35
+ "@_id": "a_edgeData",
36
+ "@_title": "data",
37
+ "@_type": "string"
38
+ },
39
+ {
40
+ "@_id": "a_sourcePort",
41
+ "@_title": "sourcePort",
42
+ "@_type": "string"
43
+ },
44
+ {
45
+ "@_id": "a_targetPort",
46
+ "@_title": "targetPort",
47
+ "@_type": "string"
26
48
  }
27
49
  ];
28
- const edgeAttrs = [{
29
- "@_id": "a_edgeData",
30
- "@_title": "data",
31
- "@_type": "string"
32
- }];
33
50
  const nodes = graph.nodes.map((n) => {
34
51
  const attvalues = [];
35
52
  if (n.parentId) attvalues.push({
@@ -48,6 +65,10 @@ function toGEXF(graph) {
48
65
  "@_for": "a_shape",
49
66
  "@_value": n.shape
50
67
  });
68
+ if (n.ports !== void 0) attvalues.push({
69
+ "@_for": "a_ports",
70
+ "@_value": JSON.stringify(n.ports)
71
+ });
51
72
  const node = {
52
73
  "@_id": n.id,
53
74
  "@_label": n.label || n.id
@@ -80,6 +101,16 @@ function toGEXF(graph) {
80
101
  "@_for": "a_edgeData",
81
102
  "@_value": JSON.stringify(e.data)
82
103
  }] };
104
+ const edgeAttvalues = edge.attvalues?.attvalue ?? [];
105
+ if (e.sourcePort !== void 0) edgeAttvalues.push({
106
+ "@_for": "a_sourcePort",
107
+ "@_value": e.sourcePort
108
+ });
109
+ if (e.targetPort !== void 0) edgeAttvalues.push({
110
+ "@_for": "a_targetPort",
111
+ "@_value": e.targetPort
112
+ });
113
+ if (edgeAttvalues.length > 0) edge.attvalues = { attvalue: edgeAttvalues };
83
114
  if (e.color) {
84
115
  const hex$1 = e.color.replace("#", "");
85
116
  if (hex$1.length === 6) edge["viz:color"] = {
@@ -90,15 +121,6 @@ function toGEXF(graph) {
90
121
  }
91
122
  return edge;
92
123
  });
93
- const graphData = [];
94
- if (graph.initialNodeId) graphData.push({
95
- "@_for": "a_initialNodeId",
96
- "@_value": graph.initialNodeId
97
- });
98
- if (graph.data !== void 0) graphData.push({
99
- "@_for": "a_data",
100
- "@_value": JSON.stringify(graph.data)
101
- });
102
124
  const obj = {
103
125
  "?xml": {
104
126
  "@_version": "1.0",
@@ -111,6 +133,9 @@ function toGEXF(graph) {
111
133
  graph: {
112
134
  "@_defaultedgetype": graph.type === "directed" ? "directed" : "undirected",
113
135
  ...graph.id && { "@_id": graph.id },
136
+ ...graph.initialNodeId && { "@_initialNodeId": graph.initialNodeId },
137
+ ...graph.direction && { "@_direction": graph.direction },
138
+ ...graph.data !== void 0 && { "@_data": JSON.stringify(graph.data) },
114
139
  attributes: [{
115
140
  "@_class": "node",
116
141
  attribute: nodeAttrs
@@ -168,6 +193,7 @@ function fromGEXF(xml) {
168
193
  data: attvals["data"] !== void 0 ? tryParseJSON(attvals["data"]) : void 0
169
194
  };
170
195
  if (attvals["shape"]) node.shape = attvals["shape"];
196
+ if (attvals["ports"] !== void 0) node.ports = tryParseJSON(attvals["ports"]);
171
197
  const pos = n["viz:position"];
172
198
  if (pos) {
173
199
  node.x = Number(pos["@_x"] ?? 0);
@@ -199,7 +225,9 @@ function fromGEXF(xml) {
199
225
  sourceId: String(e["@_source"]),
200
226
  targetId: String(e["@_target"]),
201
227
  label: e["@_label"] ?? "",
202
- data: attvals["data"] !== void 0 ? tryParseJSON(attvals["data"]) : void 0
228
+ data: attvals["data"] !== void 0 ? tryParseJSON(attvals["data"]) : void 0,
229
+ ...attvals["sourcePort"] !== void 0 && { sourcePort: attvals["sourcePort"] },
230
+ ...attvals["targetPort"] !== void 0 && { targetPort: attvals["targetPort"] }
203
231
  };
204
232
  const color = e["viz:color"];
205
233
  if (color) {
@@ -213,10 +241,11 @@ function fromGEXF(xml) {
213
241
  return {
214
242
  id: String(graphEl["@_id"] ?? ""),
215
243
  type: graphType,
216
- initialNodeId: null,
244
+ initialNodeId: graphEl["@_initialNodeId"] ?? null,
217
245
  nodes,
218
246
  edges,
219
- data: void 0
247
+ data: graphEl["@_data"] !== void 0 ? tryParseJSON(String(graphEl["@_data"])) : void 0,
248
+ ...graphEl["@_direction"] && { direction: graphEl["@_direction"] }
220
249
  };
221
250
  }
222
251
  function asArray(val) {