@quokkaquery/graph 0.0.0-stage → 0.1.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/API.md +100 -0
- package/LICENSE +21 -0
- package/README.md +186 -2
- package/dist/graph.d.ts +142 -0
- package/dist/graph.js +535 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.js +1 -0
- package/package.json +56 -4
package/API.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @quokkaquery/graph — API Reference
|
|
2
|
+
|
|
3
|
+
Distilled API surface for agent prompts and quick reference. 7 exports from `src/index.ts`.
|
|
4
|
+
|
|
5
|
+
## Types
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
export type NodeId = string | number
|
|
9
|
+
|
|
10
|
+
export interface GraphNode<T = unknown> {
|
|
11
|
+
id: NodeId
|
|
12
|
+
data: T
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Edge<E = unknown> {
|
|
16
|
+
from: NodeId
|
|
17
|
+
to: NodeId
|
|
18
|
+
weight: number
|
|
19
|
+
data: E
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GraphOptions {
|
|
23
|
+
directed?: boolean // default: true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Visitor<T> = (node: GraphNode<T>, depth: number) => void | false
|
|
27
|
+
export type NodePredicate<T> = (node: GraphNode<T>) => boolean
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Class
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
export class Graph<T = unknown, E = unknown> {
|
|
34
|
+
readonly directed: boolean
|
|
35
|
+
|
|
36
|
+
constructor(opts?: GraphOptions)
|
|
37
|
+
|
|
38
|
+
// ── Nodes ──────────────────────────────────────────
|
|
39
|
+
addNode(id: NodeId, data: T): this
|
|
40
|
+
getNode(id: NodeId): GraphNode<T> | undefined
|
|
41
|
+
hasNode(id: NodeId): boolean
|
|
42
|
+
removeNode(id: NodeId): boolean
|
|
43
|
+
get nodeCount(): number
|
|
44
|
+
nodeIds(): NodeId[]
|
|
45
|
+
nodeEntries(): GraphNode<T>[]
|
|
46
|
+
|
|
47
|
+
// ── Edges ──────────────────────────────────────────
|
|
48
|
+
addEdge(from: NodeId, to: NodeId, weight?: number, data?: E): this
|
|
49
|
+
getEdge(from: NodeId, to: NodeId): Edge<E> | undefined
|
|
50
|
+
hasEdge(from: NodeId, to: NodeId): boolean
|
|
51
|
+
removeEdge(from: NodeId, to: NodeId): boolean
|
|
52
|
+
get edgeCount(): number
|
|
53
|
+
edges(): Edge<E>[]
|
|
54
|
+
neighbors(id: NodeId): NodeId[]
|
|
55
|
+
outEdges(id: NodeId): Edge<E>[]
|
|
56
|
+
inEdges(id: NodeId): Edge<E>[]
|
|
57
|
+
outDegree(id: NodeId): number
|
|
58
|
+
inDegree(id: NodeId): number
|
|
59
|
+
degree(id: NodeId): number
|
|
60
|
+
|
|
61
|
+
// ── Node helpers ───────────────────────────────────
|
|
62
|
+
predecessors(id: NodeId): NodeId[]
|
|
63
|
+
successors(id: NodeId): NodeId[]
|
|
64
|
+
connectedNodes(id: NodeId): NodeId[]
|
|
65
|
+
isSource(id: NodeId): boolean // directed only
|
|
66
|
+
isSink(id: NodeId): boolean // directed only
|
|
67
|
+
isIsolated(id: NodeId): boolean
|
|
68
|
+
sources(): NodeId[] // directed only
|
|
69
|
+
sinks(): NodeId[] // directed only
|
|
70
|
+
isolatedNodes(): NodeId[]
|
|
71
|
+
|
|
72
|
+
// ── Reachability ───────────────────────────────────
|
|
73
|
+
reachableFrom(id: NodeId): Set<NodeId>
|
|
74
|
+
reachableTo(id: NodeId): Set<NodeId>
|
|
75
|
+
|
|
76
|
+
// ── Traversals ─────────────────────────────────────
|
|
77
|
+
bfs(start: NodeId, visitor: Visitor<T>): void
|
|
78
|
+
dfs(start: NodeId, visitor: Visitor<T>): void
|
|
79
|
+
findBfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined
|
|
80
|
+
findDfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined
|
|
81
|
+
|
|
82
|
+
// ── Algorithms ─────────────────────────────────────
|
|
83
|
+
topologicalSort(): NodeId[] // directed only, throws on cycle
|
|
84
|
+
hasCycle(): boolean
|
|
85
|
+
dijkstra(start: NodeId, end: NodeId): { distance: number; path: NodeId[] } | undefined
|
|
86
|
+
connectedComponents(): NodeId[][] // weakly-connected for directed
|
|
87
|
+
|
|
88
|
+
// ── Transforms ─────────────────────────────────────
|
|
89
|
+
mapNodes<U>(fn: (node: GraphNode<T>) => U): Graph<U, E>
|
|
90
|
+
subgraph(ids: Set<NodeId>): Graph<T, E>
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Notes
|
|
95
|
+
|
|
96
|
+
- Adjacency-list representation. Directed by default.
|
|
97
|
+
- `addEdge` auto-creates missing nodes. For undirected, both directions are stored internally.
|
|
98
|
+
- `edges()` deduplicates for undirected graphs.
|
|
99
|
+
- `reachableFrom` includes the start node only if a cycle leads back to it.
|
|
100
|
+
- Visitor callbacks can return `false` to stop traversal early.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QuokkaQuery (https://quokkaquery.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,187 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @quokkaquery/graph
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Built and maintained by [QuokkaQuery](https://quokkaquery.com). Source on [GitHub](https://github.com/Nodlume/graph).
|
|
4
|
+
|
|
5
|
+
Generic TypeScript graph library with adjacency-list representation. Supports directed and undirected graphs, weighted edges, traversals, and common graph algorithms.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @quokkaquery/graph
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Graph } from "@quokkaquery/graph";
|
|
17
|
+
|
|
18
|
+
const g = new Graph<string>({ directed: true });
|
|
19
|
+
|
|
20
|
+
g.addNode("a", "Alice")
|
|
21
|
+
.addNode("b", "Bob")
|
|
22
|
+
.addNode("c", "Carol");
|
|
23
|
+
|
|
24
|
+
g.addEdge("a", "b", 1);
|
|
25
|
+
g.addEdge("b", "c", 2);
|
|
26
|
+
g.addEdge("a", "c", 10);
|
|
27
|
+
|
|
28
|
+
// Shortest path
|
|
29
|
+
const result = g.dijkstra("a", "c");
|
|
30
|
+
// { distance: 3, path: ["a", "b", "c"] }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
### Constructor
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
new Graph<T, E>(opts?: { directed?: boolean })
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `T` — node data type (default `unknown`)
|
|
42
|
+
- `E` — edge data type (default `unknown`)
|
|
43
|
+
- `directed` — defaults to `true`
|
|
44
|
+
|
|
45
|
+
### Node Operations
|
|
46
|
+
|
|
47
|
+
| Method | Returns | Description |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `addNode(id, data)` | `this` | Add a node (chainable) |
|
|
50
|
+
| `getNode(id)` | `GraphNode<T> \| undefined` | Get node by id |
|
|
51
|
+
| `hasNode(id)` | `boolean` | Check if node exists |
|
|
52
|
+
| `removeNode(id)` | `boolean` | Remove node and all its edges |
|
|
53
|
+
| `nodeCount` | `number` | Number of nodes |
|
|
54
|
+
| `nodeIds()` | `NodeId[]` | All node ids |
|
|
55
|
+
| `nodeEntries()` | `GraphNode<T>[]` | All nodes |
|
|
56
|
+
|
|
57
|
+
### Edge Operations
|
|
58
|
+
|
|
59
|
+
| Method | Returns | Description |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `addEdge(from, to, weight?, data?)` | `this` | Add an edge (chainable, default weight 1) |
|
|
62
|
+
| `getEdge(from, to)` | `Edge<E> \| undefined` | Get edge between two nodes |
|
|
63
|
+
| `hasEdge(from, to)` | `boolean` | Check if edge exists |
|
|
64
|
+
| `removeEdge(from, to)` | `boolean` | Remove an edge |
|
|
65
|
+
| `edgeCount` | `number` | Number of edges |
|
|
66
|
+
| `edges()` | `Edge<E>[]` | All edges (deduplicated for undirected) |
|
|
67
|
+
|
|
68
|
+
### Neighbors & Degree
|
|
69
|
+
|
|
70
|
+
| Method | Returns | Description |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `neighbors(id)` | `NodeId[]` | Outgoing neighbor ids |
|
|
73
|
+
| `outEdges(id)` | `Edge<E>[]` | Outgoing edges |
|
|
74
|
+
| `inEdges(id)` | `Edge<E>[]` | Incoming edges |
|
|
75
|
+
| `outDegree(id)` | `number` | Number of outgoing edges |
|
|
76
|
+
| `inDegree(id)` | `number` | Number of incoming edges |
|
|
77
|
+
| `degree(id)` | `number` | Total degree |
|
|
78
|
+
| `predecessors(id)` | `NodeId[]` | Nodes with edges pointing to this node |
|
|
79
|
+
| `successors(id)` | `NodeId[]` | Nodes this node points to |
|
|
80
|
+
| `connectedNodes(id)` | `NodeId[]` | Union of predecessors and successors |
|
|
81
|
+
|
|
82
|
+
### Node Classification
|
|
83
|
+
|
|
84
|
+
| Method | Returns | Description |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `isSource(id)` | `boolean` | In-degree 0 (directed only) |
|
|
87
|
+
| `isSink(id)` | `boolean` | Out-degree 0 (directed only) |
|
|
88
|
+
| `isIsolated(id)` | `boolean` | Degree 0 |
|
|
89
|
+
| `sources()` | `NodeId[]` | All source nodes |
|
|
90
|
+
| `sinks()` | `NodeId[]` | All sink nodes |
|
|
91
|
+
| `isolatedNodes()` | `NodeId[]` | All isolated nodes |
|
|
92
|
+
|
|
93
|
+
### Reachability
|
|
94
|
+
|
|
95
|
+
| Method | Returns | Description |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `reachableFrom(id)` | `Set<NodeId>` | All nodes reachable via outgoing edges |
|
|
98
|
+
| `reachableTo(id)` | `Set<NodeId>` | All nodes that can reach this node |
|
|
99
|
+
|
|
100
|
+
### Traversals
|
|
101
|
+
|
|
102
|
+
| Method | Description |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `bfs(start, visitor)` | Breadth-first traversal. Return `false` from visitor to stop early. |
|
|
105
|
+
| `dfs(start, visitor)` | Depth-first traversal. Return `false` from visitor to stop early. |
|
|
106
|
+
| `findBfs(start, predicate)` | Find first node matching predicate via BFS |
|
|
107
|
+
| `findDfs(start, predicate)` | Find first node matching predicate via DFS |
|
|
108
|
+
|
|
109
|
+
### Algorithms
|
|
110
|
+
|
|
111
|
+
| Method | Description |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `topologicalSort()` | Kahn's algorithm. Throws if graph has a cycle. Directed only. |
|
|
114
|
+
| `hasCycle()` | Cycle detection (DFS for directed, Union-Find for undirected) |
|
|
115
|
+
| `dijkstra(start, end)` | Shortest path. Returns `{ distance, path }` or `undefined`. |
|
|
116
|
+
| `connectedComponents()` | Connected (undirected) or weakly-connected (directed) components |
|
|
117
|
+
|
|
118
|
+
### Utilities
|
|
119
|
+
|
|
120
|
+
| Method | Description |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `mapNodes(fn)` | Transform node data, producing a new graph |
|
|
123
|
+
| `subgraph(ids)` | Extract a subgraph containing only the given node ids |
|
|
124
|
+
|
|
125
|
+
## Types
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
type NodeId = string | number;
|
|
129
|
+
|
|
130
|
+
interface GraphNode<T> { id: NodeId; data: T }
|
|
131
|
+
|
|
132
|
+
interface Edge<E> { from: NodeId; to: NodeId; weight: number; data: E }
|
|
133
|
+
|
|
134
|
+
type Visitor<T> = (node: GraphNode<T>, depth: number) => void | false;
|
|
135
|
+
|
|
136
|
+
type NodePredicate<T> = (node: GraphNode<T>) => boolean;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npm test # run tests
|
|
143
|
+
npm run test:watch # watch mode
|
|
144
|
+
npm run build # compile TypeScript
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Gotchas
|
|
148
|
+
|
|
149
|
+
- **addEdge auto-creates nodes** with `undefined` data. If you need typed data, call `addNode` first.
|
|
150
|
+
- **Undirected edges** are stored as two directed entries internally. `edges()` deduplicates but `outEdges()`/`inEdges()` do not.
|
|
151
|
+
- **topologicalSort** throws `"Graph contains a cycle"` — catch it or check `hasCycle()` first.
|
|
152
|
+
- **dijkstra** uses an array-based priority queue — fine for moderate sizes, not for 100k+ node graphs.
|
|
153
|
+
- **reachableFrom** excludes the start node itself unless a cycle leads back to it.
|
|
154
|
+
|
|
155
|
+
## Common Patterns
|
|
156
|
+
|
|
157
|
+
### Dependency ordering
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const deps = new Graph<string>();
|
|
161
|
+
deps.addEdge("build", "lint");
|
|
162
|
+
deps.addEdge("test", "build");
|
|
163
|
+
const order = deps.topologicalSort(); // ["test", "build", "lint"]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Shortest path
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const g = new Graph<string>();
|
|
170
|
+
g.addEdge("a", "b", 4).addEdge("b", "c", 2).addEdge("a", "c", 7);
|
|
171
|
+
const result = g.dijkstra("a", "c"); // { distance: 6, path: ["a", "b", "c"] }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Filtering to a subgraph
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const important = new Set(g.nodeIds().filter(id => g.degree(id) > 3));
|
|
178
|
+
const sub = g.subgraph(important);
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT © [QuokkaQuery](https://quokkaquery.com)
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
Part of the open-source tooling behind [QuokkaQuery](https://quokkaquery.com).
|
package/dist/graph.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory `Graph` class: node and edge storage, degree and reachability
|
|
3
|
+
* queries, BFS/DFS traversal, and topological sort, cycle detection,
|
|
4
|
+
* Dijkstra, connected components, mapping and subgraph extraction.
|
|
5
|
+
*/
|
|
6
|
+
import type { NodeId, GraphNode, Edge, GraphOptions, Visitor, NodePredicate } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Generic graph with adjacency-list representation.
|
|
9
|
+
* Supports directed and undirected graphs, weighted edges, and common traversals.
|
|
10
|
+
* Undirected edges are stored as a pair of mirrored directed edges, so
|
|
11
|
+
* per-node edge lists contain both directions.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam T - Node data type
|
|
14
|
+
* @typeParam E - Edge data type
|
|
15
|
+
*/
|
|
16
|
+
export declare class Graph<T = unknown, E = unknown> {
|
|
17
|
+
readonly directed: boolean;
|
|
18
|
+
private nodes;
|
|
19
|
+
private adjOut;
|
|
20
|
+
private adjIn;
|
|
21
|
+
/** Graphs are directed unless `opts.directed` is explicitly `false`. */
|
|
22
|
+
constructor(opts?: GraphOptions);
|
|
23
|
+
/** Adds or replaces a node's data; existing edges on `id` are kept. Chainable. */
|
|
24
|
+
addNode(id: NodeId, data: T): this;
|
|
25
|
+
getNode(id: NodeId): GraphNode<T> | undefined;
|
|
26
|
+
hasNode(id: NodeId): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Deletes a node and its edges; returns false if `id` was not a registered node.
|
|
29
|
+
* Only the first edge between `id` and each other node is removed from that
|
|
30
|
+
* node's lists, so parallel edges leave dangling entries behind.
|
|
31
|
+
*/
|
|
32
|
+
removeNode(id: NodeId): boolean;
|
|
33
|
+
get nodeCount(): number;
|
|
34
|
+
nodeIds(): NodeId[];
|
|
35
|
+
nodeEntries(): GraphNode<T>[];
|
|
36
|
+
/**
|
|
37
|
+
* Appends an edge (weight defaults to 1); parallel edges are allowed. Endpoints
|
|
38
|
+
* not added via `addNode` get adjacency lists but are not registered nodes:
|
|
39
|
+
* they are absent from `nodeIds`/`getNode` and skipped by traversal visitors
|
|
40
|
+
* and `dijkstra`. Chainable.
|
|
41
|
+
*/
|
|
42
|
+
addEdge(from: NodeId, to: NodeId, weight?: number, data?: E): this;
|
|
43
|
+
/** First stored edge from `from` to `to`; with parallel edges the others are not returned. */
|
|
44
|
+
getEdge(from: NodeId, to: NodeId): Edge<E> | undefined;
|
|
45
|
+
hasEdge(from: NodeId, to: NodeId): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Removes one `from`→`to` edge (and its mirror when undirected); returns false
|
|
48
|
+
* if none exists. Parallel edges must be removed one call at a time.
|
|
49
|
+
*/
|
|
50
|
+
removeEdge(from: NodeId, to: NodeId): boolean;
|
|
51
|
+
/** Number of edges; undirected edges count once despite being stored twice. */
|
|
52
|
+
get edgeCount(): number;
|
|
53
|
+
/**
|
|
54
|
+
* All edges. For undirected graphs, one edge per node pair is returned in
|
|
55
|
+
* whichever direction was seen first, which also collapses parallel edges —
|
|
56
|
+
* so `mapNodes` and `subgraph` drop undirected parallel edges.
|
|
57
|
+
*/
|
|
58
|
+
edges(): Edge<E>[];
|
|
59
|
+
/** Outgoing neighbors of a node. */
|
|
60
|
+
neighbors(id: NodeId): NodeId[];
|
|
61
|
+
/** Outgoing edges from a node. */
|
|
62
|
+
outEdges(id: NodeId): Edge<E>[];
|
|
63
|
+
/** Incoming edges to a node (always available, even for undirected). */
|
|
64
|
+
inEdges(id: NodeId): Edge<E>[];
|
|
65
|
+
/** Out-degree of a node. */
|
|
66
|
+
outDegree(id: NodeId): number;
|
|
67
|
+
/** In-degree of a node. */
|
|
68
|
+
inDegree(id: NodeId): number;
|
|
69
|
+
/** Total degree: in + out (for undirected, equals the number of incident edges). */
|
|
70
|
+
degree(id: NodeId): number;
|
|
71
|
+
/** Predecessor node ids — nodes with an edge pointing *to* this node. */
|
|
72
|
+
predecessors(id: NodeId): NodeId[];
|
|
73
|
+
/** Successor node ids — same as `neighbors`, nodes this node points *to*. */
|
|
74
|
+
successors(id: NodeId): NodeId[];
|
|
75
|
+
/**
|
|
76
|
+
* All directly connected node ids (union of predecessors and successors).
|
|
77
|
+
* For undirected graphs this equals `neighbors`. For directed graphs it
|
|
78
|
+
* includes both incoming and outgoing connections, deduplicated.
|
|
79
|
+
*/
|
|
80
|
+
connectedNodes(id: NodeId): NodeId[];
|
|
81
|
+
/** Whether `id` is a source (no incoming edges). Directed only — always false for undirected. */
|
|
82
|
+
isSource(id: NodeId): boolean;
|
|
83
|
+
/** Whether `id` is a sink (no outgoing edges). Directed only — always false for undirected. */
|
|
84
|
+
isSink(id: NodeId): boolean;
|
|
85
|
+
/** Whether `id` has no edges at all. */
|
|
86
|
+
isIsolated(id: NodeId): boolean;
|
|
87
|
+
/** All source nodes (in-degree 0). Directed graphs only. */
|
|
88
|
+
sources(): NodeId[];
|
|
89
|
+
/** All sink nodes (out-degree 0). Directed graphs only. */
|
|
90
|
+
sinks(): NodeId[];
|
|
91
|
+
/** All isolated nodes (degree 0). */
|
|
92
|
+
isolatedNodes(): NodeId[];
|
|
93
|
+
/**
|
|
94
|
+
* All nodes reachable from `id` (transitive closure of successors).
|
|
95
|
+
* Does not include `id` itself unless there is a cycle back to it.
|
|
96
|
+
*/
|
|
97
|
+
reachableFrom(id: NodeId): Set<NodeId>;
|
|
98
|
+
/**
|
|
99
|
+
* All nodes that can reach `id` (transitive closure of predecessors).
|
|
100
|
+
* Does not include `id` itself unless there is a cycle.
|
|
101
|
+
*/
|
|
102
|
+
reachableTo(id: NodeId): Set<NodeId>;
|
|
103
|
+
/** Breadth-first traversal from a start node. */
|
|
104
|
+
bfs(start: NodeId, visitor: Visitor<T>): void;
|
|
105
|
+
/** Depth-first traversal from a start node (iterative). */
|
|
106
|
+
dfs(start: NodeId, visitor: Visitor<T>): void;
|
|
107
|
+
/** Find a node matching a predicate via BFS. */
|
|
108
|
+
findBfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined;
|
|
109
|
+
/** Find a node matching a predicate via DFS. */
|
|
110
|
+
findDfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* Topological sort (Kahn's algorithm). Returns node ids in topological order.
|
|
113
|
+
* Throws if the graph is undirected or has a cycle.
|
|
114
|
+
*/
|
|
115
|
+
topologicalSort(): NodeId[];
|
|
116
|
+
/** Detect whether the graph contains a cycle. */
|
|
117
|
+
hasCycle(): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Dijkstra's shortest path from `start` to `end`.
|
|
120
|
+
* Returns `{ distance, path }` or `undefined` if unreachable.
|
|
121
|
+
* Edges must have non-negative weights. `end` must be a registered node;
|
|
122
|
+
* otherwise the result is a one-element path with an undefined distance
|
|
123
|
+
* rather than `undefined`.
|
|
124
|
+
*/
|
|
125
|
+
dijkstra(start: NodeId, end: NodeId): {
|
|
126
|
+
distance: number;
|
|
127
|
+
path: NodeId[];
|
|
128
|
+
} | undefined;
|
|
129
|
+
/**
|
|
130
|
+
* Connected components (undirected) or weakly-connected components (directed).
|
|
131
|
+
* Returns an array of node-id arrays, one per component.
|
|
132
|
+
*/
|
|
133
|
+
connectedComponents(): NodeId[][];
|
|
134
|
+
/**
|
|
135
|
+
* Map over node data, producing a new graph with transformed data.
|
|
136
|
+
* Edges and structure are preserved.
|
|
137
|
+
*/
|
|
138
|
+
mapNodes<U>(fn: (node: GraphNode<T>) => U): Graph<U, E>;
|
|
139
|
+
/** Create a subgraph containing only the given node ids. */
|
|
140
|
+
subgraph(ids: Set<NodeId>): Graph<T, E>;
|
|
141
|
+
private ensureNode;
|
|
142
|
+
}
|
package/dist/graph.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic graph with adjacency-list representation.
|
|
3
|
+
* Supports directed and undirected graphs, weighted edges, and common traversals.
|
|
4
|
+
* Undirected edges are stored as a pair of mirrored directed edges, so
|
|
5
|
+
* per-node edge lists contain both directions.
|
|
6
|
+
*
|
|
7
|
+
* @typeParam T - Node data type
|
|
8
|
+
* @typeParam E - Edge data type
|
|
9
|
+
*/
|
|
10
|
+
export class Graph {
|
|
11
|
+
directed;
|
|
12
|
+
nodes = new Map();
|
|
13
|
+
adjOut = new Map(); // outgoing edges
|
|
14
|
+
adjIn = new Map(); // incoming edges (directed only, for convenience)
|
|
15
|
+
/** Graphs are directed unless `opts.directed` is explicitly `false`. */
|
|
16
|
+
constructor(opts = {}) {
|
|
17
|
+
this.directed = opts.directed ?? true;
|
|
18
|
+
}
|
|
19
|
+
// ── Node operations ──────────────────────────────────────────────
|
|
20
|
+
/** Adds or replaces a node's data; existing edges on `id` are kept. Chainable. */
|
|
21
|
+
addNode(id, data) {
|
|
22
|
+
this.nodes.set(id, { id, data });
|
|
23
|
+
if (!this.adjOut.has(id))
|
|
24
|
+
this.adjOut.set(id, []);
|
|
25
|
+
if (!this.adjIn.has(id))
|
|
26
|
+
this.adjIn.set(id, []);
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
getNode(id) {
|
|
30
|
+
return this.nodes.get(id);
|
|
31
|
+
}
|
|
32
|
+
hasNode(id) {
|
|
33
|
+
return this.nodes.has(id);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Deletes a node and its edges; returns false if `id` was not a registered node.
|
|
37
|
+
* Only the first edge between `id` and each other node is removed from that
|
|
38
|
+
* node's lists, so parallel edges leave dangling entries behind.
|
|
39
|
+
*/
|
|
40
|
+
removeNode(id) {
|
|
41
|
+
if (!this.nodes.has(id))
|
|
42
|
+
return false;
|
|
43
|
+
this.nodes.delete(id);
|
|
44
|
+
// Remove all edges touching this node
|
|
45
|
+
this.adjOut.delete(id);
|
|
46
|
+
this.adjIn.delete(id);
|
|
47
|
+
for (const [, edges] of this.adjOut) {
|
|
48
|
+
const idx = edges.findIndex((e) => e.to === id);
|
|
49
|
+
if (idx !== -1)
|
|
50
|
+
edges.splice(idx, 1);
|
|
51
|
+
}
|
|
52
|
+
for (const [, edges] of this.adjIn) {
|
|
53
|
+
const idx = edges.findIndex((e) => e.from === id);
|
|
54
|
+
if (idx !== -1)
|
|
55
|
+
edges.splice(idx, 1);
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
get nodeCount() {
|
|
60
|
+
return this.nodes.size;
|
|
61
|
+
}
|
|
62
|
+
nodeIds() {
|
|
63
|
+
return [...this.nodes.keys()];
|
|
64
|
+
}
|
|
65
|
+
nodeEntries() {
|
|
66
|
+
return [...this.nodes.values()];
|
|
67
|
+
}
|
|
68
|
+
// ── Edge operations ──────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Appends an edge (weight defaults to 1); parallel edges are allowed. Endpoints
|
|
71
|
+
* not added via `addNode` get adjacency lists but are not registered nodes:
|
|
72
|
+
* they are absent from `nodeIds`/`getNode` and skipped by traversal visitors
|
|
73
|
+
* and `dijkstra`. Chainable.
|
|
74
|
+
*/
|
|
75
|
+
addEdge(from, to, weight = 1, data) {
|
|
76
|
+
this.ensureNode(from);
|
|
77
|
+
this.ensureNode(to);
|
|
78
|
+
const edge = { from, to, weight, data: data };
|
|
79
|
+
this.adjOut.get(from).push(edge);
|
|
80
|
+
this.adjIn.get(to).push(edge);
|
|
81
|
+
if (!this.directed) {
|
|
82
|
+
const reverse = { from: to, to: from, weight, data: data };
|
|
83
|
+
this.adjOut.get(to).push(reverse);
|
|
84
|
+
this.adjIn.get(from).push(reverse);
|
|
85
|
+
}
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
/** First stored edge from `from` to `to`; with parallel edges the others are not returned. */
|
|
89
|
+
getEdge(from, to) {
|
|
90
|
+
return this.adjOut.get(from)?.find((e) => e.to === to);
|
|
91
|
+
}
|
|
92
|
+
hasEdge(from, to) {
|
|
93
|
+
return this.getEdge(from, to) !== undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Removes one `from`→`to` edge (and its mirror when undirected); returns false
|
|
97
|
+
* if none exists. Parallel edges must be removed one call at a time.
|
|
98
|
+
*/
|
|
99
|
+
removeEdge(from, to) {
|
|
100
|
+
const out = this.adjOut.get(from);
|
|
101
|
+
if (!out)
|
|
102
|
+
return false;
|
|
103
|
+
const idx = out.findIndex((e) => e.to === to);
|
|
104
|
+
if (idx === -1)
|
|
105
|
+
return false;
|
|
106
|
+
out.splice(idx, 1);
|
|
107
|
+
const inEdges = this.adjIn.get(to);
|
|
108
|
+
if (inEdges) {
|
|
109
|
+
const iIdx = inEdges.findIndex((e) => e.from === from);
|
|
110
|
+
if (iIdx !== -1)
|
|
111
|
+
inEdges.splice(iIdx, 1);
|
|
112
|
+
}
|
|
113
|
+
if (!this.directed) {
|
|
114
|
+
const revOut = this.adjOut.get(to);
|
|
115
|
+
if (revOut) {
|
|
116
|
+
const rIdx = revOut.findIndex((e) => e.to === from);
|
|
117
|
+
if (rIdx !== -1)
|
|
118
|
+
revOut.splice(rIdx, 1);
|
|
119
|
+
}
|
|
120
|
+
const revIn = this.adjIn.get(from);
|
|
121
|
+
if (revIn) {
|
|
122
|
+
const rIdx = revIn.findIndex((e) => e.from === to);
|
|
123
|
+
if (rIdx !== -1)
|
|
124
|
+
revIn.splice(rIdx, 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/** Number of edges; undirected edges count once despite being stored twice. */
|
|
130
|
+
get edgeCount() {
|
|
131
|
+
let count = 0;
|
|
132
|
+
for (const [, edges] of this.adjOut)
|
|
133
|
+
count += edges.length;
|
|
134
|
+
return this.directed ? count : count / 2;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* All edges. For undirected graphs, one edge per node pair is returned in
|
|
138
|
+
* whichever direction was seen first, which also collapses parallel edges —
|
|
139
|
+
* so `mapNodes` and `subgraph` drop undirected parallel edges.
|
|
140
|
+
*/
|
|
141
|
+
edges() {
|
|
142
|
+
const all = [];
|
|
143
|
+
for (const [, edges] of this.adjOut)
|
|
144
|
+
all.push(...edges);
|
|
145
|
+
if (!this.directed) {
|
|
146
|
+
// Deduplicate: keep the first-seen direction of each node pair
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
return all.filter((e) => {
|
|
149
|
+
const key = `${e.from}->${e.to}`;
|
|
150
|
+
const rev = `${e.to}->${e.from}`;
|
|
151
|
+
if (seen.has(key) || seen.has(rev))
|
|
152
|
+
return false;
|
|
153
|
+
seen.add(key);
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return all;
|
|
158
|
+
}
|
|
159
|
+
/** Outgoing neighbors of a node. */
|
|
160
|
+
neighbors(id) {
|
|
161
|
+
return (this.adjOut.get(id) ?? []).map((e) => e.to);
|
|
162
|
+
}
|
|
163
|
+
/** Outgoing edges from a node. */
|
|
164
|
+
outEdges(id) {
|
|
165
|
+
return this.adjOut.get(id) ?? [];
|
|
166
|
+
}
|
|
167
|
+
/** Incoming edges to a node (always available, even for undirected). */
|
|
168
|
+
inEdges(id) {
|
|
169
|
+
return this.adjIn.get(id) ?? [];
|
|
170
|
+
}
|
|
171
|
+
/** Out-degree of a node. */
|
|
172
|
+
outDegree(id) {
|
|
173
|
+
return (this.adjOut.get(id) ?? []).length;
|
|
174
|
+
}
|
|
175
|
+
/** In-degree of a node. */
|
|
176
|
+
inDegree(id) {
|
|
177
|
+
return (this.adjIn.get(id) ?? []).length;
|
|
178
|
+
}
|
|
179
|
+
/** Total degree: in + out (for undirected, equals the number of incident edges). */
|
|
180
|
+
degree(id) {
|
|
181
|
+
return this.directed
|
|
182
|
+
? this.inDegree(id) + this.outDegree(id)
|
|
183
|
+
: this.outDegree(id); // undirected stores both directions, so outDegree already counts all
|
|
184
|
+
}
|
|
185
|
+
// ── Node-level helpers (for custom traversals) ───────────────────
|
|
186
|
+
/** Predecessor node ids — nodes with an edge pointing *to* this node. */
|
|
187
|
+
predecessors(id) {
|
|
188
|
+
return (this.adjIn.get(id) ?? []).map((e) => e.from);
|
|
189
|
+
}
|
|
190
|
+
/** Successor node ids — same as `neighbors`, nodes this node points *to*. */
|
|
191
|
+
successors(id) {
|
|
192
|
+
return this.neighbors(id);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* All directly connected node ids (union of predecessors and successors).
|
|
196
|
+
* For undirected graphs this equals `neighbors`. For directed graphs it
|
|
197
|
+
* includes both incoming and outgoing connections, deduplicated.
|
|
198
|
+
*/
|
|
199
|
+
connectedNodes(id) {
|
|
200
|
+
if (!this.directed)
|
|
201
|
+
return this.neighbors(id);
|
|
202
|
+
const set = new Set();
|
|
203
|
+
for (const e of this.adjOut.get(id) ?? [])
|
|
204
|
+
set.add(e.to);
|
|
205
|
+
for (const e of this.adjIn.get(id) ?? [])
|
|
206
|
+
set.add(e.from);
|
|
207
|
+
return [...set];
|
|
208
|
+
}
|
|
209
|
+
/** Whether `id` is a source (no incoming edges). Directed only — always false for undirected. */
|
|
210
|
+
isSource(id) {
|
|
211
|
+
return this.directed && this.inDegree(id) === 0 && this.hasNode(id);
|
|
212
|
+
}
|
|
213
|
+
/** Whether `id` is a sink (no outgoing edges). Directed only — always false for undirected. */
|
|
214
|
+
isSink(id) {
|
|
215
|
+
return this.directed && this.outDegree(id) === 0 && this.hasNode(id);
|
|
216
|
+
}
|
|
217
|
+
/** Whether `id` has no edges at all. */
|
|
218
|
+
isIsolated(id) {
|
|
219
|
+
return this.degree(id) === 0 && this.hasNode(id);
|
|
220
|
+
}
|
|
221
|
+
/** All source nodes (in-degree 0). Directed graphs only. */
|
|
222
|
+
sources() {
|
|
223
|
+
if (!this.directed)
|
|
224
|
+
return [];
|
|
225
|
+
return this.nodeIds().filter((id) => this.inDegree(id) === 0);
|
|
226
|
+
}
|
|
227
|
+
/** All sink nodes (out-degree 0). Directed graphs only. */
|
|
228
|
+
sinks() {
|
|
229
|
+
if (!this.directed)
|
|
230
|
+
return [];
|
|
231
|
+
return this.nodeIds().filter((id) => this.outDegree(id) === 0);
|
|
232
|
+
}
|
|
233
|
+
/** All isolated nodes (degree 0). */
|
|
234
|
+
isolatedNodes() {
|
|
235
|
+
return this.nodeIds().filter((id) => this.degree(id) === 0);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* All nodes reachable from `id` (transitive closure of successors).
|
|
239
|
+
* Does not include `id` itself unless there is a cycle back to it.
|
|
240
|
+
*/
|
|
241
|
+
reachableFrom(id) {
|
|
242
|
+
const reached = new Set();
|
|
243
|
+
const stack = [...this.neighbors(id)];
|
|
244
|
+
while (stack.length > 0) {
|
|
245
|
+
const cur = stack.pop();
|
|
246
|
+
if (reached.has(cur))
|
|
247
|
+
continue;
|
|
248
|
+
reached.add(cur);
|
|
249
|
+
for (const next of this.neighbors(cur)) {
|
|
250
|
+
if (!reached.has(next))
|
|
251
|
+
stack.push(next);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return reached;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* All nodes that can reach `id` (transitive closure of predecessors).
|
|
258
|
+
* Does not include `id` itself unless there is a cycle.
|
|
259
|
+
*/
|
|
260
|
+
reachableTo(id) {
|
|
261
|
+
const reached = new Set();
|
|
262
|
+
const stack = [...this.predecessors(id)];
|
|
263
|
+
while (stack.length > 0) {
|
|
264
|
+
const cur = stack.pop();
|
|
265
|
+
if (reached.has(cur))
|
|
266
|
+
continue;
|
|
267
|
+
reached.add(cur);
|
|
268
|
+
for (const prev of this.predecessors(cur)) {
|
|
269
|
+
if (!reached.has(prev))
|
|
270
|
+
stack.push(prev);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return reached;
|
|
274
|
+
}
|
|
275
|
+
// ── Traversals ───────────────────────────────────────────────────
|
|
276
|
+
/** Breadth-first traversal from a start node. */
|
|
277
|
+
bfs(start, visitor) {
|
|
278
|
+
const visited = new Set();
|
|
279
|
+
const queue = [{ id: start, depth: 0 }];
|
|
280
|
+
visited.add(start);
|
|
281
|
+
while (queue.length > 0) {
|
|
282
|
+
const { id, depth } = queue.shift();
|
|
283
|
+
const node = this.nodes.get(id);
|
|
284
|
+
if (!node)
|
|
285
|
+
continue;
|
|
286
|
+
if (visitor(node, depth) === false)
|
|
287
|
+
return;
|
|
288
|
+
for (const nId of this.neighbors(id)) {
|
|
289
|
+
if (!visited.has(nId)) {
|
|
290
|
+
visited.add(nId);
|
|
291
|
+
queue.push({ id: nId, depth: depth + 1 });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Depth-first traversal from a start node (iterative). */
|
|
297
|
+
dfs(start, visitor) {
|
|
298
|
+
const visited = new Set();
|
|
299
|
+
const stack = [{ id: start, depth: 0 }];
|
|
300
|
+
while (stack.length > 0) {
|
|
301
|
+
const { id, depth } = stack.pop();
|
|
302
|
+
if (visited.has(id))
|
|
303
|
+
continue;
|
|
304
|
+
visited.add(id);
|
|
305
|
+
const node = this.nodes.get(id);
|
|
306
|
+
if (!node)
|
|
307
|
+
continue;
|
|
308
|
+
if (visitor(node, depth) === false)
|
|
309
|
+
return;
|
|
310
|
+
// Push neighbors in reverse so the first neighbor is visited first
|
|
311
|
+
const nbrs = this.neighbors(id);
|
|
312
|
+
for (let i = nbrs.length - 1; i >= 0; i--) {
|
|
313
|
+
if (!visited.has(nbrs[i]))
|
|
314
|
+
stack.push({ id: nbrs[i], depth: depth + 1 });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Find a node matching a predicate via BFS. */
|
|
319
|
+
findBfs(start, predicate) {
|
|
320
|
+
let found;
|
|
321
|
+
this.bfs(start, (node) => {
|
|
322
|
+
if (predicate(node)) {
|
|
323
|
+
found = node;
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
return found;
|
|
328
|
+
}
|
|
329
|
+
/** Find a node matching a predicate via DFS. */
|
|
330
|
+
findDfs(start, predicate) {
|
|
331
|
+
let found;
|
|
332
|
+
this.dfs(start, (node) => {
|
|
333
|
+
if (predicate(node)) {
|
|
334
|
+
found = node;
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
return found;
|
|
339
|
+
}
|
|
340
|
+
// ── Algorithms ───────────────────────────────────────────────────
|
|
341
|
+
/**
|
|
342
|
+
* Topological sort (Kahn's algorithm). Returns node ids in topological order.
|
|
343
|
+
* Throws if the graph is undirected or has a cycle.
|
|
344
|
+
*/
|
|
345
|
+
topologicalSort() {
|
|
346
|
+
if (!this.directed)
|
|
347
|
+
throw new Error("Topological sort requires a directed graph");
|
|
348
|
+
const inDeg = new Map();
|
|
349
|
+
for (const id of this.nodes.keys())
|
|
350
|
+
inDeg.set(id, 0);
|
|
351
|
+
for (const [, edges] of this.adjOut) {
|
|
352
|
+
for (const e of edges)
|
|
353
|
+
inDeg.set(e.to, (inDeg.get(e.to) ?? 0) + 1);
|
|
354
|
+
}
|
|
355
|
+
const queue = [];
|
|
356
|
+
for (const [id, deg] of inDeg)
|
|
357
|
+
if (deg === 0)
|
|
358
|
+
queue.push(id);
|
|
359
|
+
const result = [];
|
|
360
|
+
while (queue.length > 0) {
|
|
361
|
+
const id = queue.shift();
|
|
362
|
+
result.push(id);
|
|
363
|
+
for (const e of this.adjOut.get(id) ?? []) {
|
|
364
|
+
const newDeg = inDeg.get(e.to) - 1;
|
|
365
|
+
inDeg.set(e.to, newDeg);
|
|
366
|
+
if (newDeg === 0)
|
|
367
|
+
queue.push(e.to);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (result.length !== this.nodes.size)
|
|
371
|
+
throw new Error("Graph contains a cycle");
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
/** Detect whether the graph contains a cycle. */
|
|
375
|
+
hasCycle() {
|
|
376
|
+
if (this.directed) {
|
|
377
|
+
// DFS-based: white(0)/gray(1)/black(2)
|
|
378
|
+
const color = new Map();
|
|
379
|
+
for (const id of this.nodes.keys())
|
|
380
|
+
color.set(id, 0);
|
|
381
|
+
const dfs = (id) => {
|
|
382
|
+
color.set(id, 1);
|
|
383
|
+
for (const e of this.adjOut.get(id) ?? []) {
|
|
384
|
+
if (color.get(e.to) === 1)
|
|
385
|
+
return true;
|
|
386
|
+
if (color.get(e.to) === 0 && dfs(e.to))
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
color.set(id, 2);
|
|
390
|
+
return false;
|
|
391
|
+
};
|
|
392
|
+
for (const id of this.nodes.keys()) {
|
|
393
|
+
if (color.get(id) === 0 && dfs(id))
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
// Union-Find for undirected
|
|
400
|
+
const parent = new Map();
|
|
401
|
+
const find = (x) => {
|
|
402
|
+
while (parent.get(x) !== x) {
|
|
403
|
+
parent.set(x, parent.get(parent.get(x)));
|
|
404
|
+
x = parent.get(x);
|
|
405
|
+
}
|
|
406
|
+
return x;
|
|
407
|
+
};
|
|
408
|
+
for (const id of this.nodes.keys())
|
|
409
|
+
parent.set(id, id);
|
|
410
|
+
for (const edge of this.edges()) {
|
|
411
|
+
const rootA = find(edge.from);
|
|
412
|
+
const rootB = find(edge.to);
|
|
413
|
+
if (rootA === rootB)
|
|
414
|
+
return true;
|
|
415
|
+
parent.set(rootA, rootB);
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Dijkstra's shortest path from `start` to `end`.
|
|
422
|
+
* Returns `{ distance, path }` or `undefined` if unreachable.
|
|
423
|
+
* Edges must have non-negative weights. `end` must be a registered node;
|
|
424
|
+
* otherwise the result is a one-element path with an undefined distance
|
|
425
|
+
* rather than `undefined`.
|
|
426
|
+
*/
|
|
427
|
+
dijkstra(start, end) {
|
|
428
|
+
const dist = new Map();
|
|
429
|
+
const prev = new Map();
|
|
430
|
+
const visited = new Set();
|
|
431
|
+
// Simple priority queue (array-based — fine for moderate graph sizes)
|
|
432
|
+
const pq = [];
|
|
433
|
+
for (const id of this.nodes.keys())
|
|
434
|
+
dist.set(id, Infinity);
|
|
435
|
+
dist.set(start, 0);
|
|
436
|
+
prev.set(start, null);
|
|
437
|
+
pq.push({ id: start, d: 0 });
|
|
438
|
+
while (pq.length > 0) {
|
|
439
|
+
pq.sort((a, b) => a.d - b.d);
|
|
440
|
+
const { id } = pq.shift();
|
|
441
|
+
if (visited.has(id))
|
|
442
|
+
continue;
|
|
443
|
+
visited.add(id);
|
|
444
|
+
if (id === end)
|
|
445
|
+
break;
|
|
446
|
+
for (const e of this.adjOut.get(id) ?? []) {
|
|
447
|
+
const alt = dist.get(id) + e.weight;
|
|
448
|
+
if (alt < dist.get(e.to)) {
|
|
449
|
+
dist.set(e.to, alt);
|
|
450
|
+
prev.set(e.to, id);
|
|
451
|
+
pq.push({ id: e.to, d: alt });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (dist.get(end) === Infinity)
|
|
456
|
+
return undefined;
|
|
457
|
+
const path = [];
|
|
458
|
+
let cur = end;
|
|
459
|
+
while (cur !== null) {
|
|
460
|
+
path.unshift(cur);
|
|
461
|
+
cur = prev.get(cur) ?? null;
|
|
462
|
+
}
|
|
463
|
+
return { distance: dist.get(end), path };
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Connected components (undirected) or weakly-connected components (directed).
|
|
467
|
+
* Returns an array of node-id arrays, one per component.
|
|
468
|
+
*/
|
|
469
|
+
connectedComponents() {
|
|
470
|
+
const visited = new Set();
|
|
471
|
+
const components = [];
|
|
472
|
+
for (const id of this.nodes.keys()) {
|
|
473
|
+
if (visited.has(id))
|
|
474
|
+
continue;
|
|
475
|
+
const component = [];
|
|
476
|
+
// BFS ignoring direction
|
|
477
|
+
const queue = [id];
|
|
478
|
+
visited.add(id);
|
|
479
|
+
while (queue.length > 0) {
|
|
480
|
+
const cur = queue.shift();
|
|
481
|
+
component.push(cur);
|
|
482
|
+
for (const e of this.adjOut.get(cur) ?? []) {
|
|
483
|
+
if (!visited.has(e.to)) {
|
|
484
|
+
visited.add(e.to);
|
|
485
|
+
queue.push(e.to);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (this.directed) {
|
|
489
|
+
for (const e of this.adjIn.get(cur) ?? []) {
|
|
490
|
+
if (!visited.has(e.from)) {
|
|
491
|
+
visited.add(e.from);
|
|
492
|
+
queue.push(e.from);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
components.push(component);
|
|
498
|
+
}
|
|
499
|
+
return components;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Map over node data, producing a new graph with transformed data.
|
|
503
|
+
* Edges and structure are preserved.
|
|
504
|
+
*/
|
|
505
|
+
mapNodes(fn) {
|
|
506
|
+
const g = new Graph({ directed: this.directed });
|
|
507
|
+
for (const node of this.nodes.values())
|
|
508
|
+
g.addNode(node.id, fn(node));
|
|
509
|
+
for (const edge of this.edges())
|
|
510
|
+
g.addEdge(edge.from, edge.to, edge.weight, edge.data);
|
|
511
|
+
return g;
|
|
512
|
+
}
|
|
513
|
+
/** Create a subgraph containing only the given node ids. */
|
|
514
|
+
subgraph(ids) {
|
|
515
|
+
const g = new Graph({ directed: this.directed });
|
|
516
|
+
for (const id of ids) {
|
|
517
|
+
const node = this.nodes.get(id);
|
|
518
|
+
if (node)
|
|
519
|
+
g.addNode(id, node.data);
|
|
520
|
+
}
|
|
521
|
+
for (const edge of this.edges()) {
|
|
522
|
+
if (ids.has(edge.from) && ids.has(edge.to)) {
|
|
523
|
+
g.addEdge(edge.from, edge.to, edge.weight, edge.data);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return g;
|
|
527
|
+
}
|
|
528
|
+
// ── Internal ─────────────────────────────────────────────────────
|
|
529
|
+
ensureNode(id) {
|
|
530
|
+
if (!this.adjOut.has(id)) {
|
|
531
|
+
this.adjOut.set(id, []);
|
|
532
|
+
this.adjIn.set(id, []);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry point for the graph package: re-exports the `Graph` class and
|
|
3
|
+
* its supporting types, so consumers never import from internal module paths.
|
|
4
|
+
* The types are exported with `export type`, so they disappear at compile time
|
|
5
|
+
* and only `Graph` remains as a runtime export.
|
|
6
|
+
*/
|
|
7
|
+
export { Graph } from "./graph.js";
|
|
8
|
+
export type { NodeId, GraphNode, Edge, GraphOptions, Visitor, NodePredicate, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry point for the graph package: re-exports the `Graph` class and
|
|
3
|
+
* its supporting types, so consumers never import from internal module paths.
|
|
4
|
+
* The types are exported with `export type`, so they disappear at compile time
|
|
5
|
+
* and only `Graph` remains as a runtime export.
|
|
6
|
+
*/
|
|
7
|
+
export { Graph } from "./graph.js";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Unique identifier for a node — typically a string or number. */
|
|
2
|
+
export type NodeId = string | number;
|
|
3
|
+
/** A node in the graph, holding an id and arbitrary data. */
|
|
4
|
+
export interface GraphNode<T = unknown> {
|
|
5
|
+
id: NodeId;
|
|
6
|
+
data: T;
|
|
7
|
+
}
|
|
8
|
+
/** An edge connecting two nodes, with optional weight and metadata. */
|
|
9
|
+
export interface Edge<E = unknown> {
|
|
10
|
+
from: NodeId;
|
|
11
|
+
to: NodeId;
|
|
12
|
+
weight: number;
|
|
13
|
+
data: E;
|
|
14
|
+
}
|
|
15
|
+
/** Options when constructing a graph. */
|
|
16
|
+
export interface GraphOptions {
|
|
17
|
+
directed?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Visitor callback for traversals. Return `false` to stop early. */
|
|
20
|
+
export type Visitor<T> = (node: GraphNode<T>, depth: number) => void | false;
|
|
21
|
+
/** Filter predicate for search methods. */
|
|
22
|
+
export type NodePredicate<T> = (node: GraphNode<T>) => boolean;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quokkaquery/graph",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Generic reusable graph library with structure representation and traversal methods",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"graph",
|
|
7
|
+
"data-structure",
|
|
8
|
+
"traversal",
|
|
9
|
+
"bfs",
|
|
10
|
+
"dfs",
|
|
11
|
+
"topological-sort",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://quokkaquery.com",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Nodlume/graph.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/Nodlume/graph/issues"
|
|
21
|
+
},
|
|
22
|
+
"author": {
|
|
23
|
+
"name": "QuokkaQuery",
|
|
24
|
+
"url": "https://quokkaquery.com"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "dist/index.js",
|
|
29
|
+
"types": "dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js",
|
|
34
|
+
"require": "./dist/index.js",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"API.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
"prepublishOnly": "npm run build"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"typescript": "^5.7.0",
|
|
56
|
+
"vitest": "^3.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|