diff --git a/README.md b/README.md index a47d958..69fc055 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,6 @@ A collection of JavaScript utility libraries: - [@haydn/geometry-fns](https://github.com/haydn/fns/tree/main/packages/geometry-fns) +- [graph-fns](https://github.com/haydn/fns/tree/main/packages/graph-fns) - [@haydn/grid-fns](https://github.com/haydn/fns/tree/main/packages/grid-fns) - [@haydn/linear-fns](https://github.com/haydn/fns/tree/main/packages/linear-fns) diff --git a/bunup.config.ts b/bunup.config.ts index e0ef572..0a6f2ed 100644 --- a/bunup.config.ts +++ b/bunup.config.ts @@ -11,6 +11,11 @@ export default defineWorkspace([ root: "packages/geometry-fns", config, }, + { + name: "graph-fns", + root: "packages/graph-fns", + config, + }, { name: "grid-fns", root: "packages/grid-fns", diff --git a/package.json b/package.json index 6892d33..43dffe6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "add-change": "bun run changeset add", "build": "bunup src/index.ts", "docs": "bun run --filter '*' docs", - "publish": "bun run --cwd packages/linear-fns publish && bun run --cwd packages/geometry-fns publish && bun run --cwd packages/grid-fns publish", + "publish": "bun run --cwd packages/linear-fns publish && bun run --cwd packages/geometry-fns publish && bun run --cwd packages/grid-fns publish && bun run --cwd packages/graph-fns publish", "test": "bun test" }, "type": "module", diff --git a/packages/graph-fns/LICENSE b/packages/graph-fns/LICENSE new file mode 100644 index 0000000..485cd9e --- /dev/null +++ b/packages/graph-fns/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-2025 Haydn Ewers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/graph-fns/README.md b/packages/graph-fns/README.md new file mode 100644 index 0000000..3a98e08 --- /dev/null +++ b/packages/graph-fns/README.md @@ -0,0 +1,713 @@ +
+

+ graph-fns +

+

A JavaScript utility library for working with graphs.

+

+ npm bundle size + npm +

+
+ +## Features + +- Lightweight. +- Pure functions. +- TypeScript declarations included. +- ESM package. + +## Installation + +```bash +bun add graph-fns +deno add npm:graph-fns +npm install graph-fns +pnpm add graph-fns +yarn add graph-fns +``` + +## Demo + +https://h2788.csb.app/ + +![](screenshot.png) + +## Usage + +```js +import { create, addEdge, isCyclic, topologicalSort, degree, addVertex } from "graph-fns"; + +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = addEdge(graph, ["A", "C"]); +//=> Graph { "A" -> "C", "B" } + +graph = addEdge(graph, ["B", "A"]); +//=> Graph { "A" -> "C", "B" -> "A" } + +isCyclic(graph); +//=> false + +topologicalSort(graph); +//=> ["B", "A", "C"] + +degree(graph, "A"); +//=> 2 + +graph = addVertex(graph, "D"); +//=> Graph { "A" -> "C", "B" -> "A", "D" } + +graph = addEdge(graph, ["C", "D"]); +//=> Graph { "A" -> "C", "B" -> "A", "C" -> "D" } + +descendants(graph, "A"); +//=> Set { "C", "D" } + +graph = addEdge(graph, ["D", "B"]); +//=> Graph { "A" -> "C", "B" -> "A", "C" -> "D", "D" -> "B" } + +isCyclic(graph); +//=> true +``` + + + +## Functions + +- [addEdge](#addedge) +- [addVertex](#addvertex) +- [ancestors](#ancestors) +- [children](#children) +- [clone](#clone) +- [create](#create) +- [degree](#degree) +- [descendants](#descendants) +- [edges](#edges) +- [fromD3](#fromd3) +- [getEdge](#getedge) +- [indegree](#indegree) +- [isCyclic](#iscyclic) +- [isUndirected](#isundirected) +- [makeUndirected](#makeundirected) +- [order](#order) +- [outdegree](#outdegree) +- [parents](#parents) +- [removeEdge](#removeedge) +- [removeVertex](#removevertex) +- [setEdge](#setedge) +- [size](#size) +- [toD3](#tod3) +- [toDirected](#todirected) +- [topologicalSort](#topologicalsort) +- [transpose](#transpose) +- [vertices](#vertices) +- [vertexPairs](#vertexpairs) + +### addEdge + +Adds a new edge to the graph from vertex `u` to vertex `v`. + +**Note**: `addEdge(graph, edge)` is equivalent to `setEdge(graph, edge, 1)`. + +Also see: + +- [removeEdge](#removeEdge) +- [getEdge](#getEdge) +- [setEdge](#setEdge) + +| Function | Type | +| ---------- | ---------- | +| `addEdge` | `(graph: Graph, [u, v]: Edge, options?: { undirected?: boolean or undefined; } or undefined) => Graph` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = addEdge(graph, ["A", "B"]); +//=> Graph { "A" -> "B", "C" } +``` + + +### addVertex + +Adds a new vertex to the graph. The new vertex will not have any edges +connecting it to existing vertices in the graph. + +**Note**: If the vertex already exists the graph will be returned unmodified. + +Also see: + +- [removeVertex](#removeVertex) + +| Function | Type | +| ---------- | ---------- | +| `addVertex` | `(graph: Graph, vertex: T2) => Graph` | + +### ancestors + +Given a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph), returns +all ancestors of the given vertex (i.e. vertices from which there is a +directed path to the given vertex). + +**Note**: If the given graph contains cycles (checked with +[isCyclic](#isCyclic)), an error will be thrown. + +Also see: + +- [descendants](#descendants) +- [parents](#parents) + +| Function | Type | +| ---------- | ---------- | +| `ancestors` | `(graph: Graph, vertex: T2) => Set` | + +### children + +Returns all the vertices that are children of the given vertex (i.e. there is +an edge starting at the given vertex going to the child vertex). + +**Note**: If there is an edge that both starts and ends at the given vertex, +it will be considered a child of itself and included in the result. + +Also see: + +- [parents](#parents) +- [descendants](#descendants) + +| Function | Type | +| ---------- | ---------- | +| `children` | `(graph: Graph, vertex: T2) => Set` | + +### clone + +Creates a copy of the graph. + +| Function | Type | +| ---------- | ---------- | +| `clone` | `(graph: Graph) => Graph` | + +### create + +| Function | Type | +| ---------- | ---------- | +| `create` | `(vertices: T[]) => Graph` | + +### degree + +Returns the [degree]() +for the given vertex. + +By default `weighted` is `false`, if set to `true` the result will be the sum +of the edge weights (which could be zero or a negative value). + +Also see: + +- [indegree](#indegree) +- [outdegree](#outdegree) + +| Function | Type | +| ---------- | ---------- | +| `degree` | `(graph: Graph, vertex: T2, options?: { weighted?: boolean or undefined; undirected?: boolean or undefined; } or undefined) => number` | + +### descendants + +Given a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph), returns +all descendants of the given vertex (i.e. vertices to which there is a +directed path from the given vertex). + +**Note**: If the given graph contains cycles (checked with +[isCyclic](#isCyclic)), an error will be thrown. + +Also see: + +- [ancestors](#ancestors) +- [children](#children) + +| Function | Type | +| ---------- | ---------- | +| `descendants` | `(graph: Graph, vertex: T2) => Set` | + +### edges + +Returns all the edges in the graph (i.e. any edge with a value other than +`0`). + +| Function | Type | +| ---------- | ---------- | +| `edges` | `(graph: Graph, options?: { undirected?: boolean or undefined; } or undefined) => Set>` | + +### fromD3 + +Converts a graph from a [D3Graph](#D3Graph) representation into a +[Graph](#Graph) representation. + +When the D3Graph contains multiple links between two nodes the resulting +graph will have inflated edge weights to reflect that. + +**Note**: Any extraneous data associated with nodes or links in the D3Graph representation will be ignored. + +Also see: + +- [toD3](#toD3) + +| Function | Type | +| ---------- | ---------- | +| `fromD3` | `(d3Graph: D3Graph, options?: { undirected?: boolean or undefined; } or undefined) => Graph` | + +Examples: + +```js +const graph = fromD3({ + nodes: [{ id: "A" }, { id: "B" }, { id: "C" }], + links: [ + { source: "A", target: "B" }, + { source: "A", target: "C" }, + { source: "A", target: "C" }, + ], +}); +//=> Graph { "A" -> "B", "A" -> "C" } + +getEdge(["A", "B"]); +//=> 1 +getEdge(["A", "C"]); +//=> 2 +``` + + +### getEdge + +Get the weight of the given edge. + +Also see: + +- [addEdge](#addEdge) +- [removeEdge](#removeEdge) +- [setEdge](#setEdge) + +| Function | Type | +| ---------- | ---------- | +| `getEdge` | `(graph: Graph, [u, v]: Edge) => number` | + +### indegree + +Returns the [indegree](https://en.wikipedia.org/wiki/Indegree) for the given +vertex. + +By default `weighted` is `false`, if set to `true` the result will be the sum +of the edge weights (which could be zero or a negative value). + +Also see: + +- [degree](#degree) +- [outdegree](#outdegree) + +| Function | Type | +| ---------- | ---------- | +| `indegree` | `(graph: Graph, vertex: T2, options?: { weighted?: boolean or undefined; } or undefined) => number` | + +### isCyclic + +Returns `true` if the graph provided contains any +[cycles]() (including +"loops" — an edge that starts and ends at the same vertex), otherwise returns +`false`. + +| Function | Type | +| ---------- | ---------- | +| `isCyclic` | `(graph: Graph, options?: { undirected?: boolean or undefined; } or undefined) => boolean` | + +### isUndirected + +Returns `true` if the graph can be considered an [undirected +graph](https://mathinsight.org/definition/undirected_graph) — every edge in +the graph (from vertex A to B) has a mutual edge (from vertex B to A) with an +equal weight. Loops are considered bidirectional and are allow in a +undirected graph. + +| Function | Type | +| ---------- | ---------- | +| `isUndirected` | `(graph: Graph) => boolean` | + +Examples: + +```js +let graph = create(["A", "B"]); +//=> Graph { "A", "B" } + +isUndirected(graph); +//=> true + +graph = addEdge(graph, ["A", "B"]); +//=> Graph { "A" -> "B" } + +isUndirected(graph); +//=> false + +graph = addEdge(graph, ["B", "A"]); +//=> Graph { "A" <-> "B" } + +isUndirected(graph); +//=> true +``` + + +### makeUndirected + +Converts a directed graph to an undirected graph by either adding edges to +make them mutual or balancing the weights of mutual edges that aren't already +equal. + +The `merge` function is used to determine the weight of edges in cases where +mutual edges with differing weights already exist. If not provide the default +method is to use the highest of the two edge weights (`(a, b) => Math.max(a, +b)`). + +| Function | Type | +| ---------- | ---------- | +| `makeUndirected` | `(graph: Graph, merge?: (a: number, b: number) => number) => Graph` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = addEdge(graph, ["A", "B"]); +//=> Graph { "A" -> "B", "C" } + +graph = makeUndirected(graph); +//=> Graph { "A" <-> "B", "C" } +``` + + +### order + +Returns the number of vertices in the graph. + +Also see: + +- [size](#size) + +| Function | Type | +| ---------- | ---------- | +| `order` | `(graph: Graph) => number` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +order(graph); +//=> 3 +``` + + +### outdegree + +Returns the [outdegree](https://en.wikipedia.org/wiki/Outdegree) for the +given vertex. + +By default `weighted` is `false`, if set to `true` the result will be the sum +of the edge weights (which could be zero or a negative value). + +Also see: + +- [degree](#degree) +- [indegree](#indegree) + +| Function | Type | +| ---------- | ---------- | +| `outdegree` | `(graph: Graph, vertex: T2, options?: { weighted?: boolean or undefined; } or undefined) => number` | + +### parents + +Returns all the vertices that are parents of the given vertex (i.e. there is +an edge starting at the parent vertex going to the given vertex). + +**Note**: If there is an edge that both starts and ends at the given vertex, +it will be considered a parent of itself and included in the result. + +Also see: + +- [ancestors](#ancestors) +- [children](#children) + +| Function | Type | +| ---------- | ---------- | +| `parents` | `(graph: Graph, vertex: T2) => Set` | + +### removeEdge + +Removes an edge from a graph. + +**Note**: `removeEdge(graph, edge)` is equivalent to `setEdge(graph, edge, 0)`. + +Also see: + +- [addEdge](#addEdge) +- [getEdge](#getEdge) +- [setEdge](#setEdge) + +| Function | Type | +| ---------- | ---------- | +| `removeEdge` | `(graph: Graph, [u, v]: [T2, T2], options?: { undirected?: boolean or undefined; } or undefined) => Graph` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = addEdge(graph, ["A", "B"]); +//=> Graph { "A" -> "B", "C" } + +graph = removeEdge(graph, ["A", "B"]); +//=> Graph { "A", "B", "C" } +``` + + +### removeVertex + +Removes a vertex from a graph. + +Also see: + +- [addVertex](#addVertex) + +| Function | Type | +| ---------- | ---------- | +| `removeVertex` | `(graph: Graph, vertex: T2) => Graph>` | + +### setEdge + +Set the weight of the given edge. + +**Note**: `setEdge(graph, edge, 1)` is equivalent to `addEdge(graph, edge)` +and `setEdge(graph, edge, 0)` is equivalent to `removeEdge(graph, edge)`. + +Also see: + +- [addEdge](#addEdge) +- [getEdge](#getEdge) +- [removeEdge](#removeEdge) + +| Function | Type | +| ---------- | ---------- | +| `setEdge` | `(graph: Graph, [u, v]: Edge, weight: number, options?: { undirected?: boolean or undefined; } or undefined) => Graph` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = setEdge(graph, ["A", "B"], 1); +//=> Graph { "A" -> "B", "C" } + +graph = setEdge(graph, ["A", "B"], 0); +//=> Graph { "A", "B", "C" } +``` + + +### size + +Returns the number of edges in the graph. + +Also see: + +- [order](#order) + +| Function | Type | +| ---------- | ---------- | +| `size` | `(graph: Graph, options?: { undirected?: boolean or undefined; } or undefined) => number` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = addEdge(graph, ["A", "B"]); +//=> Graph { "A" -> "B", "C" } + +graph = addEdge(graph, ["B", "C"]); +//=> Graph { "A" -> "B", "B" -> "C" } + +size(graph); +//=> 2 +``` + + +### toD3 + +Converts a graph from a [Graph](#Graph) representation into a [D3Graph](#D3Graph) representation. + +Edges with a weight of 2 or greater will result in multiple links being generated in the D3Graph. + +Also see: + +- [fromD3](#fromD3) + +| Function | Type | +| ---------- | ---------- | +| `toD3` | `(graph: Graph, options?: { undirected?: boolean or undefined; } or undefined) => D3Graph` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = setEdge(graph, ["A", "B"], 1); +//=> Graph { "A" -> "B", "C" } + +graph = setEdge(graph, ["A", "C"], 2); +//=> Graph { "A" -> "B", "A" -> "C" } + +toD3(graph); +//=> { +// nodes: [{ id: "A" }, { id: "B" }, { id: "C" }], +// links: [ +// { source: "A", target: "B" }, +// { source: "A", target: "C" }, +// { source: "A", target: "C" }, +// ], +// } +``` + + +### toDirected + +Converts an undirected graph to a directed graph by converting reciprocal +edges to a single directed edge. + +| Function | Type | +| ---------- | ---------- | +| `toDirected` | `(graph: Graph) => Graph` | + +### topologicalSort + +Given a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph), returns +an array of the graph's vertices sorted using a [topological +sort](https://en.wikipedia.org/wiki/Topological_sorting). + +**Note**: If the given graph contains cycles (checked with +[isCyclic](#isCyclic)), an error will be thrown. + +| Function | Type | +| ---------- | ---------- | +| `topologicalSort` | `(graph: Graph) => string[]` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = addEdge(graph, ["A", "C"]); +//=> Graph { "A" -> "C", "B" } + +graph = addEdge(graph, ["C", "B"]); +//=> Graph { "A" -> "C", "C" -> "B" } + +topologicalSort(graph); +//=> ["A", "C", "B"] +``` + + +### transpose + +Flips the orientation of all edges in a directed graph. + +| Function | Type | +| ---------- | ---------- | +| `transpose` | `(graph: Graph) => Graph` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +graph = addEdge(graph, ["A", "B"]); +//=> Graph { "A" -> "B", "C" } + +graph = addEdge(graph, ["B", "C"]); +//=> Graph { "A" -> "B", "B" -> "C" } + +transpose(graph); +//=> Graph { "B" -> "A", "C" -> "B" } +``` + + +### vertices + +Returns the vertices in the graph. + +| Function | Type | +| ---------- | ---------- | +| `vertices` | `(graph: Graph) => Set` | + +### vertexPairs + +Returns a list of all pairs of vertices in the graph irrespective of the edges present in the graph. + +| Function | Type | +| ---------- | ---------- | +| `vertexPairs` | `(graph: Graph) => Set>` | + +Examples: + +```js +let graph = create(["A", "B", "C"]); +//=> Graph { "A", "B", "C" } + +vertexPairs(graph); +//=> Set { ["A", "A"], ["A", "B"], ["A", "C"], ["B", "B"], ["B", "C"], ["C", "C"] } +``` + + + + +## Types + +- [D3Graph](#d3graph) +- [Edge](#edge) +- [Graph](#graph) + +### D3Graph + +A graph in a representation convenient for using with [D3.js force-directed +graphs](https://github.com/d3/d3-force). + +| Type | Type | +| ---------- | ---------- | +| `D3Graph` | `{ nodes: Array<{ id: T; }>; links: Array<{ source: T; target: T; }>; }` | + +### Edge + +A connection between two vertices in a graph. +([Wikipedia]()) + +| Type | Type | +| ---------- | ---------- | +| `Edge` | `[T, T]` | + +### Graph + +A graph [adjacency matrix](https://en.wikipedia.org/wiki/Adjacency_matrix) +where each number in the matrix describes the edge from vertex `u` to vertex +`v`. By default, a value of `1` is used to indicate there is a edge between +the two vertices, but any value other than `0` can be used to signify the +presence of an edge (i.e. a weighted graph). ``` + +| Type | Type | +| ---------- | ---------- | +| `Graph` | `Record>` | + + + diff --git a/packages/graph-fns/logo.png b/packages/graph-fns/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6788daa7c38183072889ba3de76044a26489a3fe GIT binary patch literal 16517 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 E0P)QD+W-In literal 0 HcmV?d00001 diff --git a/packages/graph-fns/package.json b/packages/graph-fns/package.json new file mode 100644 index 0000000..11efbee --- /dev/null +++ b/packages/graph-fns/package.json @@ -0,0 +1,27 @@ +{ + "devDependencies": { + "tsdoc-markdown": "^1.4.1" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["CHANGELOG.md", "dist", "LICENSE", "logo.png", "README.md"], + "keywords": ["graph", "utility", "graph theory", "networks"], + "license": "MIT", + "main": "./dist/index.js", + "name": "graph-fns", + "private": false, + "repository": { + "type": "git", + "url": "git+https://github.com/haydn/fns.git" + }, + "scripts": { + "docs": "tsdoc --src=src/index.ts --types --noemoji", + "publish": "(npm view $(npm view ./ name)@$(npm view ./ version) > /dev/null 2>&1) && (echo \"This version has already been published. Skipping.\") || (bun pm pack --filename package.tgz && npm publish ../../package.tgz --access public --tag=latest)" + }, + "type": "module", + "version": "0.4.0" +} diff --git a/packages/graph-fns/screenshot.png b/packages/graph-fns/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..7486b28e27d449675a13041c7b8a093e93c8d58b GIT binary patch literal 74826 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 literal 0 HcmV?d00001 diff --git a/packages/graph-fns/src/addEdge.test.ts b/packages/graph-fns/src/addEdge.test.ts new file mode 100644 index 0000000..130552a --- /dev/null +++ b/packages/graph-fns/src/addEdge.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { addEdge } from "../src"; + +describe("addEdge", () => { + test("base case", () => { + expect( + addEdge( + { + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + ), + ).toEqual({ + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }); + }); + + test("adding an edge that already exists should be a no-op", () => { + expect( + addEdge( + { + a: { a: 0, b: 1.5 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + ), + ).toEqual({ + a: { a: 0, b: 1.5 }, + b: { a: 0, b: 0 }, + }); + }); + + test("add undirected edge", () => { + expect( + addEdge( + { + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + { undirected: true }, + ), + ).toEqual({ + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/addVertex.test.ts b/packages/graph-fns/src/addVertex.test.ts new file mode 100644 index 0000000..97fef01 --- /dev/null +++ b/packages/graph-fns/src/addVertex.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { addVertex } from "../src"; + +describe("addVertex", () => { + test("base case", () => { + expect(addVertex({}, "a")).toEqual({ + a: { a: 0 }, + }); + }); + + test("adding a vertex to an existing graph should create empty rows and columns", () => { + expect( + addVertex( + { + a: { a: 0 }, + }, + "b", + ), + ).toEqual({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }); + }); + + test("adding a vertex that already exists should be a no-op", () => { + expect( + addVertex( + { + a: { a: 0 }, + }, + "a", + ), + ).toEqual({ + a: { a: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/ancestors.test.ts b/packages/graph-fns/src/ancestors.test.ts new file mode 100644 index 0000000..4f5ac5c --- /dev/null +++ b/packages/graph-fns/src/ancestors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { ancestors } from "../src"; + +describe("ancestors", () => { + test('simple line { "A" -> "B", "B" -> "C" }', () => { + expect( + ancestors( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 0, b: 0, c: 0 }, + }, + "c", + ), + ).toEqual(new Set(["a", "b"])); + }); + + test('multiple paths { "A" -> "B", "B" -> "C", "B" -> "D", "C" -> "D" }', () => { + expect( + ancestors( + { + a: { a: 0, b: 1, c: 0, d: 0 }, + b: { a: 0, b: 0, c: 1, d: 1 }, + c: { a: 0, b: 0, c: 0, d: 1 }, + d: { a: 0, b: 0, c: 0, d: 0 }, + }, + "d", + ), + ).toEqual(new Set(["a", "b", "c"])); + }); + + test("graphs with cycles should throw an error", () => { + expect(() => { + ancestors( + { + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }, + "b", + ); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/children.test.ts b/packages/graph-fns/src/children.test.ts new file mode 100644 index 0000000..c08c731 --- /dev/null +++ b/packages/graph-fns/src/children.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { children } from "../src"; + +describe("children", () => { + test("basic", () => { + expect( + children( + { + a: { a: 0, b: 1, c: 1 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }, + "a", + ), + ).toEqual(new Set(["b", "c"])); + }); + + test("loops should cause the vertex to be listed as a child of itself", () => { + expect( + children( + { + a: { a: 1 }, + }, + "a", + ), + ).toEqual(new Set(["a"])); + }); +}); diff --git a/packages/graph-fns/src/clone.test.ts b/packages/graph-fns/src/clone.test.ts new file mode 100644 index 0000000..217141a --- /dev/null +++ b/packages/graph-fns/src/clone.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { clone } from "../src"; + +describe("clone", () => { + test("cloning should return a graph exactly equal to the original graph", () => { + const original = { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }; + + expect(clone(original)).toEqual(original); + }); + + test("cloning should make a copy of the graph", () => { + const original = { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }; + + expect(clone(original)).not.toBe(original); + expect(clone(original).a).not.toBe(original.a); + expect(clone(original).b).not.toBe(original.b); + }); +}); diff --git a/packages/graph-fns/src/create.test.ts b/packages/graph-fns/src/create.test.ts new file mode 100644 index 0000000..617ce66 --- /dev/null +++ b/packages/graph-fns/src/create.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { create } from "../src"; + +describe("create", () => { + test("base case", () => { + expect(create([])).toEqual({}); + }); + test("custom IDs are provided", () => { + expect(create(["a", "b"])).toEqual({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/degree.test.ts b/packages/graph-fns/src/degree.test.ts new file mode 100644 index 0000000..c44f259 --- /dev/null +++ b/packages/graph-fns/src/degree.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { degree } from "../src"; + +describe("degree", () => { + test("base case", () => { + expect(degree({ a: { a: 0 } }, "a")).toBe(0); + }); + + test("the unweighted degree should be the count of edges", () => { + expect( + degree( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 2, b: 0, c: 0 }, + c: { a: -0.5, b: 0, c: 0 }, + }, + "a", + ), + ).toBe(3); + }); + + test("the weighted degree should be the sum of edge weights", () => { + expect( + degree( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 2, b: 0, c: 0 }, + c: { a: -0.5, b: 0, c: 0 }, + }, + "a", + { weighted: true }, + ), + ).toBe(2.5); + }); + + test("loops should count twice towards the degree", () => { + expect( + degree( + { + a: { a: 1.5 }, + }, + "a", + ), + ).toBe(2); + }); + + test("loops should count twice towards the weighted degree", () => { + expect( + degree( + { + a: { a: 1.5 }, + }, + "a", + { weighted: true }, + ), + ).toBe(3); + }); + + test("reciprocal edges should only be counted once in undirected mode", () => { + expect( + degree( + { + a: { a: 0, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }, + "a", + { undirected: true }, + ), + ).toBe(2); + }); + + test("using the undirected option on a directed graph should throw an error", () => { + expect(() => { + degree( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + "a", + { undirected: true }, + ); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/descendants.test.ts b/packages/graph-fns/src/descendants.test.ts new file mode 100644 index 0000000..a355155 --- /dev/null +++ b/packages/graph-fns/src/descendants.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { descendants } from "../src"; + +describe("descendants", () => { + test('simple line { "A" -> "B", "B" -> "C" }', () => { + expect( + descendants( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 0, b: 0, c: 0 }, + }, + "a", + ), + ).toEqual(new Set(["b", "c"])); + }); + + test('multiple paths { "A" -> "B", "B" -> "C", "B" -> "D", "C" -> "D" }', () => { + expect( + descendants( + { + a: { a: 0, b: 1, c: 0, d: 0 }, + b: { a: 0, b: 0, c: 1, d: 1 }, + c: { a: 0, b: 0, c: 0, d: 1 }, + d: { a: 0, b: 0, c: 0, d: 0 }, + }, + "a", + ), + ).toEqual(new Set(["b", "c", "d"])); + }); + + test("graphs with cycles should throw an error", () => { + expect(() => { + descendants( + { + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }, + "a", + ); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/edges.test.ts b/packages/graph-fns/src/edges.test.ts new file mode 100644 index 0000000..62becbc --- /dev/null +++ b/packages/graph-fns/src/edges.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { edges } from "../src"; + +describe("edges", () => { + test("base case", () => { + expect(edges({})).toEqual(new Set([])); + }); + + test("simple graph", () => { + expect( + edges({ + a: { a: 0, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual( + new Set([ + ["a", "b"], + ["b", "c"], + ]), + ); + }); + + test("weighted edges", () => { + expect( + edges({ + a: { a: 0, b: 2, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 0.5, b: 0, c: 0 }, + }), + ).toEqual( + new Set([ + ["a", "b"], + ["b", "c"], + ["c", "a"], + ]), + ); + }); + + test("undirected mode", () => { + expect( + edges( + { + a: { a: 1, b: 1, c: 0 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }, + { undirected: true }, + ), + ).toEqual( + new Set([ + ["a", "a"], + ["a", "b"], + ["b", "c"], + ]), + ); + }); + + test("using the undirected option on a directed graph should throw an error", () => { + expect(() => { + edges( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/fromD3.test.ts b/packages/graph-fns/src/fromD3.test.ts new file mode 100644 index 0000000..986a6fc --- /dev/null +++ b/packages/graph-fns/src/fromD3.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { fromD3 } from "../src"; + +describe("fromD3", () => { + test("basic", () => { + expect( + fromD3({ + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + links: [ + { source: "a", target: "b" }, + { source: "a", target: "c" }, + { source: "a", target: "a" }, + ], + }), + ).toEqual({ + a: { a: 1, b: 1, c: 1 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }); + }); + + test("undirected", () => { + expect( + fromD3( + { + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + links: [ + { source: "a", target: "a" }, + { source: "a", target: "b" }, + { source: "a", target: "c" }, + ], + }, + { undirected: true }, + ), + ).toEqual({ + a: { a: 1, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/getEdge.test.ts b/packages/graph-fns/src/getEdge.test.ts new file mode 100644 index 0000000..c0ad999 --- /dev/null +++ b/packages/graph-fns/src/getEdge.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { getEdge } from "../src"; + +describe("getEdge", () => { + test("basic", () => { + expect( + getEdge( + { + a: { a: 0, b: 1.5 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + ), + ).toEqual(1.5); + }); +}); diff --git a/packages/graph-fns/src/indegree.test.ts b/packages/graph-fns/src/indegree.test.ts new file mode 100644 index 0000000..09f2543 --- /dev/null +++ b/packages/graph-fns/src/indegree.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { indegree } from "../src"; + +describe("indegree", () => { + test("unweighted", () => { + expect( + indegree( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 2, b: 0, c: 0 }, + c: { a: 0.5, b: 0, c: 0 }, + }, + "a", + ), + ).toEqual(2); + }); + + test("weighted", () => { + expect( + indegree( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 2, b: 0, c: 0 }, + c: { a: 0.5, b: 0, c: 0 }, + }, + "a", + { weighted: true }, + ), + ).toEqual(2.5); + }); +}); diff --git a/packages/graph-fns/src/index.ts b/packages/graph-fns/src/index.ts new file mode 100644 index 0000000..dce4782 --- /dev/null +++ b/packages/graph-fns/src/index.ts @@ -0,0 +1,944 @@ +// types + +/** + * A graph in a representation convenient for using with [D3.js force-directed + * graphs](https://github.com/d3/d3-force). + */ +export type D3Graph = { + nodes: Array<{ + id: T; + }>; + links: Array<{ + source: T; + target: T; + }>; +}; + +/** + * A connection between two vertices in a graph. + * ([Wikipedia]()) + */ +export type Edge = [T, T]; + +/** + * A graph [adjacency matrix](https://en.wikipedia.org/wiki/Adjacency_matrix) + * where each number in the matrix describes the edge from vertex `u` to vertex + * `v`. By default, a value of `1` is used to indicate there is a edge between + * the two vertices, but any value other than `0` can be used to signify the + * presence of an edge (i.e. a weighted graph). ``` + */ +export type Graph = Record>; + +// functions + +/** + * Adds a new edge to the graph from vertex `u` to vertex `v`. + * + * **Note**: `addEdge(graph, edge)` is equivalent to `setEdge(graph, edge, 1)`. + * + * Also see: + * + * - [removeEdge](#removeEdge) + * - [getEdge](#getEdge) + * - [setEdge](#setEdge) + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = addEdge(graph, ["A", "B"]); + * //=> Graph { "A" -> "B", "C" } + * ``` + */ +export const addEdge = ( + graph: Graph, + [u, v]: Edge, + options?: { undirected?: boolean }, +): Graph => { + const result = clone(graph); + + if (result[u][v] === 0) { + result[u][v] = 1; + } + + if (options?.undirected && result[v][u] === 0) { + result[v][u] = 1; + } + + return result; +}; + +/** + * Adds a new vertex to the graph. The new vertex will not have any edges + * connecting it to existing vertices in the graph. + * + * **Note**: If the vertex already exists the graph will be returned unmodified. + * + * Also see: + * + * - [removeVertex](#removeVertex) + */ +export const addVertex = ( + graph: Graph, + vertex: T2, +): Graph => { + if (vertex in graph) return graph as Graph; + + const result = clone(graph) as Graph; + + for (const v of Object.keys(result) as Array) result[v][vertex] = 0; + result[vertex] = {} as Graph[T1 | T2]; + for (const v of Object.keys(result) as Array) result[vertex][v] = 0; + + return result; +}; + +/** + * Given a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph), returns + * all ancestors of the given vertex (i.e. vertices from which there is a + * directed path to the given vertex). + * + * **Note**: If the given graph contains cycles (checked with + * [isCyclic](#isCyclic)), an error will be thrown. + * + * Also see: + * + * - [descendants](#descendants) + * - [parents](#parents) + */ +export const ancestors = ( + graph: Graph, + vertex: T2, +): Set => { + if (isCyclic(graph)) throw "Cannot retrieve ancestors in a graph that contains cycles."; + + let result: Set = new Set(); + + for (const parent of parents(graph, vertex)) { + result = new Set([...result, parent, ...ancestors(graph, parent)]); + } + + return result; +}; + +/** + * Returns all the vertices that are children of the given vertex (i.e. there is + * an edge starting at the given vertex going to the child vertex). + * + * **Note**: If there is an edge that both starts and ends at the given vertex, + * it will be considered a child of itself and included in the result. + * + * Also see: + * + * - [parents](#parents) + * - [descendants](#descendants) + */ +export const children = ( + graph: Graph, + vertex: T2, +): Set => { + const result: Set = new Set(); + const vertices = Object.keys(graph) as Array; + + for (const v of vertices) { + if (graph[vertex][v] !== 0) { + result.add(v); + } + } + + return result; +}; + +/** + * Creates a copy of the graph. + */ +export const clone = (graph: Graph): Graph => { + const result = {} as Graph; + + for (const u in graph) { + result[u] = {} as Graph[T]; + for (const v in graph[u]) { + result[u][v] = graph[u][v]; + } + } + + return result; +}; /** + * Creates a new graph. The new graph can be seeded with an optional number of + * vertices, but it will not contain any edges. + * + * The `vertices` argument is a list of vertices with which to seed the graph. + * Additional vertices can be added using [addVertex](#addVertex), but it is + * more efficient to create them upfront when possible. + * + * To create a graph using existing ID's you can use a pattern like this: + * + * @example + * ```js + * const users = [ + * { id: "412", name: "Jane" }, + * { id: "34", name: "Kate" }, + * { id: "526", name: "Mike" }, + * { id: "155", name: "Tony" }, + * ]; + * + * const graph = create(users.map((user) => user.id)); + * ``` + */ +export const create = (vertices: Array): Graph => { + const result = {} as Graph; + + for (const u of vertices) { + result[u] = {} as Graph[T]; + for (const v of vertices) { + result[u][v] = 0; + } + } + + return result; +}; + +/** + * Returns the [degree]() + * for the given vertex. + * + * By default `weighted` is `false`, if set to `true` the result will be the sum + * of the edge weights (which could be zero or a negative value). + * + * Also see: + * + * - [indegree](#indegree) + * - [outdegree](#outdegree) + */ +export const degree = ( + graph: Graph, + vertex: T2, + options?: { weighted?: boolean; undirected?: boolean }, +): number => { + if (options?.undirected && !isUndirected(graph)) { + throw Error( + "Unable to calculate degree. Expected an undirected graph, but got a directed graph.", + ); + } + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; + return ( + indegree(resolvedGraph, vertex, { weighted: options?.weighted }) + + outdegree(resolvedGraph, vertex, { weighted: options?.weighted }) + ); +}; + +/** + * Given a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph), returns + * all descendants of the given vertex (i.e. vertices to which there is a + * directed path from the given vertex). + * + * **Note**: If the given graph contains cycles (checked with + * [isCyclic](#isCyclic)), an error will be thrown. + * + * Also see: + * + * - [ancestors](#ancestors) + * - [children](#children) + */ +export const descendants = ( + graph: Graph, + vertex: T2, +): Set => { + if (isCyclic(graph)) { + throw Error("Cannot retrieve descendants in a graph that contains cycles."); + } + + let result: Set = new Set(); + + for (const child of children(graph, vertex)) { + result = new Set([...result, child, ...descendants(graph, child)]); + } + + return result; +}; + +/** + * Returns all the edges in the graph (i.e. any edge with a value other than + * `0`). + */ +export const edges = ( + graph: Graph, + options?: { undirected?: boolean }, +): Set> => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; + const result: Set> = new Set([]); + const vertices = Object.keys(resolvedGraph) as Array; + + for (const u of vertices) { + for (const v of vertices) { + if (resolvedGraph[u][v] !== 0) { + result.add([u, v]); + } + } + } + + return result; +}; + +/** + * Converts a graph from a [D3Graph](#D3Graph) representation into a + * [Graph](#Graph) representation. + * + * When the D3Graph contains multiple links between two nodes the resulting + * graph will have inflated edge weights to reflect that. + * + * **Note**: Any extraneous data associated with nodes or links in the D3Graph representation will be ignored. + * + * Also see: + * + * - [toD3](#toD3) + * + * @example + * ```js + * const graph = fromD3({ + * nodes: [{ id: "A" }, { id: "B" }, { id: "C" }], + * links: [ + * { source: "A", target: "B" }, + * { source: "A", target: "C" }, + * { source: "A", target: "C" }, + * ], + * }); + * //=> Graph { "A" -> "B", "A" -> "C" } + * + * getEdge(["A", "B"]); + * //=> 1 + * getEdge(["A", "C"]); + * //=> 2 + * ``` + */ +export const fromD3 = ( + d3Graph: D3Graph, + options?: { undirected?: boolean }, +): Graph => { + const result = {} as Graph; + + for (const u of d3Graph.nodes) { + result[u.id] = {} as Graph[T]; + for (const v of d3Graph.nodes) { + result[u.id][v.id] = 0; + } + } + + for (const { source: u, target: v } of d3Graph.links) { + result[u][v] = result[u][v] === 0 ? 1 : result[u][v] + 1; + if (options?.undirected && u !== v) result[v][u] = result[v][u] === 0 ? 1 : result[v][u] + 1; + } + + return result; +}; + +/** + * Get the weight of the given edge. + * + * Also see: + * + * - [addEdge](#addEdge) + * - [removeEdge](#removeEdge) + * - [setEdge](#setEdge) + */ +export const getEdge = (graph: Graph, [u, v]: Edge): number => + graph[u][v]; + +/** + * Returns the [indegree](https://en.wikipedia.org/wiki/Indegree) for the given + * vertex. + * + * By default `weighted` is `false`, if set to `true` the result will be the sum + * of the edge weights (which could be zero or a negative value). + * + * Also see: + * + * - [degree](#degree) + * - [outdegree](#outdegree) + */ +export const indegree = ( + graph: Graph, + vertex: T2, + options?: { weighted?: boolean }, +): number => { + let result = 0; + + for (const u in graph) { + if (graph[u][vertex] !== 0) { + result += options?.weighted ? graph[u][vertex] : 1; + } + } + + return result; +}; + +/** + * Returns `true` if the graph provided contains any + * [cycles]() (including + * "loops" — an edge that starts and ends at the same vertex), otherwise returns + * `false`. + */ +export const isCyclic = (graph: Graph, options?: { undirected?: boolean }): boolean => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + + const visited: Set = new Set(); + + for (const i in graph) { + const cycleFound = visited.has(i) + ? false + : options?.undirected + ? _isCyclicUndirected(graph, visited, undefined, i) + : _isCyclicDirected(graph, visited, new Set(), i); + if (cycleFound) return true; + } + + return false; +}; + +const _isCyclicDirected = ( + graph: Graph, + visited: Set, + path: Set, + vertex: string, +): boolean => { + visited.add(vertex); + path.add(vertex); + + for (const i in graph[vertex]) { + if (graph[vertex][i] !== 0) { + const cycleFound = path.has(i) ? true : _isCyclicDirected(graph, visited, path, i); + if (cycleFound) return true; + } + } + + path.delete(vertex); + + return false; +}; + +const _isCyclicUndirected = ( + graph: Graph, + visited: Set, + parent: string | undefined, + vertex: string, +): boolean => { + visited.add(vertex); + + for (const i in graph[vertex]) { + if (graph[vertex][i] !== 0) { + const cycleFound = visited.has(i) + ? i !== parent + : _isCyclicUndirected(graph, visited, vertex, i); + if (cycleFound) return true; + } + } + + return false; +}; + +/** + * Returns `true` if the graph can be considered an [undirected + * graph](https://mathinsight.org/definition/undirected_graph) — every edge in + * the graph (from vertex A to B) has a mutual edge (from vertex B to A) with an + * equal weight. Loops are considered bidirectional and are allow in a + * undirected graph. + * + * @example + * ```js + * let graph = create(["A", "B"]); + * //=> Graph { "A", "B" } + * + * isUndirected(graph); + * //=> true + * + * graph = addEdge(graph, ["A", "B"]); + * //=> Graph { "A" -> "B" } + * + * isUndirected(graph); + * //=> false + * + * graph = addEdge(graph, ["B", "A"]); + * //=> Graph { "A" <-> "B" } + * + * isUndirected(graph); + * //=> true + * ``` + */ +export const isUndirected = (graph: Graph): boolean => { + for (const [u, v] of vertexPairs(graph)) { + if (graph[u]?.[v] !== graph[v]?.[u]) return false; + } + return true; +}; + +/** + * Converts a directed graph to an undirected graph by either adding edges to + * make them mutual or balancing the weights of mutual edges that aren't already + * equal. + * + * The `merge` function is used to determine the weight of edges in cases where + * mutual edges with differing weights already exist. If not provide the default + * method is to use the highest of the two edge weights (`(a, b) => Math.max(a, + * b)`). + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = addEdge(graph, ["A", "B"]); + * //=> Graph { "A" -> "B", "C" } + * + * graph = makeUndirected(graph); + * //=> Graph { "A" <-> "B", "C" } + * ``` + */ +export const makeUndirected = ( + graph: Graph, + merge: (a: number, b: number) => number = (a, b) => Math.max(a, b), +): Graph => { + const result = clone(graph); + + for (const [u, v] of vertexPairs(graph)) { + const weight = + u === v || graph[u][v] === 0 || graph[v][u] === 0 + ? graph[u][v] || graph[v][u] + : merge(graph[u][v], graph[v][u]); + result[u][v] = weight; + result[v][u] = weight; + } + + return result; +}; + +/** + * Returns the number of vertices in the graph. + * + * Also see: + * + * - [size](#size) + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * order(graph); + * //=> 3 + * ``` + */ +export const order = (graph: Graph): number => { + let result = 0; + for (const _ in graph) result += 1; + return result; +}; + +/** + * Returns the [outdegree](https://en.wikipedia.org/wiki/Outdegree) for the + * given vertex. + * + * By default `weighted` is `false`, if set to `true` the result will be the sum + * of the edge weights (which could be zero or a negative value). + * + * Also see: + * + * - [degree](#degree) + * - [indegree](#indegree) + */ +export const outdegree = ( + graph: Graph, + vertex: T2, + options?: { weighted?: boolean }, +): number => { + let result = 0; + + for (const v in graph[vertex]) { + if (graph[vertex][v] !== 0) { + result += options?.weighted ? graph[vertex][v] : 1; + } + } + + return result; +}; + +/** + * Returns all the vertices that are parents of the given vertex (i.e. there is + * an edge starting at the parent vertex going to the given vertex). + * + * **Note**: If there is an edge that both starts and ends at the given vertex, + * it will be considered a parent of itself and included in the result. + * + * Also see: + * + * - [ancestors](#ancestors) + * - [children](#children) + */ +export const parents = ( + graph: Graph, + vertex: T2, +): Set => { + const result: Set = new Set(); + + for (const u in graph) { + if (graph[u][vertex] !== 0) { + result.add(u); + } + } + + return result; +}; + +/** + * Removes an edge from a graph. + * + * **Note**: `removeEdge(graph, edge)` is equivalent to `setEdge(graph, edge, 0)`. + * + * Also see: + * + * - [addEdge](#addEdge) + * - [getEdge](#getEdge) + * - [setEdge](#setEdge) + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = addEdge(graph, ["A", "B"]); + * //=> Graph { "A" -> "B", "C" } + * + * graph = removeEdge(graph, ["A", "B"]); + * //=> Graph { "A", "B", "C" } + * ``` + */ +export const removeEdge = ( + graph: Graph, + [u, v]: [T2, T2], + options?: { undirected?: boolean }, +): Graph => { + const result = clone(graph); + + result[u][v] = 0; + if (options?.undirected) result[v][u] = 0; + + return result; +}; + +/** + * Removes a vertex from a graph. + * + * Also see: + * + * - [addVertex](#addVertex) + */ +export const removeVertex = ( + graph: Graph, + vertex: T2, +): Graph> => { + const result = {} as Graph>; + const vertices = Object.keys(graph) as Array; + const newVertices = vertices.filter((x): x is Exclude => x !== vertex); + + for (const u of newVertices) { + result[u] = {} as Graph>[Exclude]; + for (const v of newVertices) { + result[u][v] = graph[u][v]; + } + } + + return result; +}; + +/** + * Set the weight of the given edge. + * + * **Note**: `setEdge(graph, edge, 1)` is equivalent to `addEdge(graph, edge)` + * and `setEdge(graph, edge, 0)` is equivalent to `removeEdge(graph, edge)`. + * + * Also see: + * + * - [addEdge](#addEdge) + * - [getEdge](#getEdge) + * - [removeEdge](#removeEdge) + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = setEdge(graph, ["A", "B"], 1); + * //=> Graph { "A" -> "B", "C" } + * + * graph = setEdge(graph, ["A", "B"], 0); + * //=> Graph { "A", "B", "C" } + * ``` + */ +export const setEdge = ( + graph: Graph, + [u, v]: Edge, + weight: number, + options?: { undirected?: boolean }, +): Graph => { + const result = clone(graph); + + result[u][v] = weight; + if (options?.undirected) result[v][u] = weight; + + return result; +}; + +/** + * Returns the number of edges in the graph. + * + * Also see: + * + * - [order](#order) + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = addEdge(graph, ["A", "B"]); + * //=> Graph { "A" -> "B", "C" } + * + * graph = addEdge(graph, ["B", "C"]); + * //=> Graph { "A" -> "B", "B" -> "C" } + * + * size(graph); + * //=> 2 + * ``` + */ +export const size = (graph: Graph, options?: { undirected?: boolean }): number => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; + let result = 0; + + for (const u in resolvedGraph) { + for (const v in resolvedGraph[u]) { + if (resolvedGraph[u][v] !== 0) { + result += 1; + } + } + } + + return result; +}; + +/** + * Converts a graph from a [Graph](#Graph) representation into a [D3Graph](#D3Graph) representation. + * + * Edges with a weight of 2 or greater will result in multiple links being generated in the D3Graph. + * + * Also see: + * + * - [fromD3](#fromD3) + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = setEdge(graph, ["A", "B"], 1); + * //=> Graph { "A" -> "B", "C" } + * + * graph = setEdge(graph, ["A", "C"], 2); + * //=> Graph { "A" -> "B", "A" -> "C" } + * + * toD3(graph); + * //=> { + * // nodes: [{ id: "A" }, { id: "B" }, { id: "C" }], + * // links: [ + * // { source: "A", target: "B" }, + * // { source: "A", target: "C" }, + * // { source: "A", target: "C" }, + * // ], + * // } + * ``` + */ +export const toD3 = ( + graph: Graph, + options?: { undirected?: boolean }, +): D3Graph => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; + const nodes: Array<{ id: T }> = []; + const links: Array<{ source: T; target: T }> = []; + const vertices = Object.keys(resolvedGraph) as Array; + + for (const u of vertices) { + nodes[nodes.length] = { id: u }; + for (const v of vertices) { + if (resolvedGraph[u][v] !== 0) { + let i = resolvedGraph[u][v]; + do { + links.push({ source: u, target: v }); + i -= 1; + } while (i > 0); + } + } + } + + return { nodes, links }; +}; + +/** + * Converts an undirected graph to a directed graph by converting reciprocal + * edges to a single directed edge. + */ +export const toDirected = (graph: Graph): Graph => { + if (!isUndirected(graph)) { + return graph; + } + + const result = {} as Graph; + const vertices = Object.keys(graph) as Array; + + for (let u = 0; u < vertices.length; u++) { + const uVertex = vertices[u] as T; + result[uVertex] = {} as Graph[T]; + for (let v = 0; v < vertices.length; v++) { + const vVertex = vertices[v] as T; + result[uVertex][vVertex] = v >= u ? graph[uVertex][vVertex] : 0; + } + } + + return result; +}; + +/** + * Given a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph), returns + * an array of the graph's vertices sorted using a [topological + * sort](https://en.wikipedia.org/wiki/Topological_sorting). + * + * **Note**: If the given graph contains cycles (checked with + * [isCyclic](#isCyclic)), an error will be thrown. + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = addEdge(graph, ["A", "C"]); + * //=> Graph { "A" -> "C", "B" } + * + * graph = addEdge(graph, ["C", "B"]); + * //=> Graph { "A" -> "C", "C" -> "B" } + * + * topologicalSort(graph); + * //=> ["A", "C", "B"] + * ``` + */ +export const topologicalSort = (graph: Graph): Array => { + if (isCyclic(graph)) throw Error("Cannot sort a graph that contains cycles."); + + const result: Array = []; + const visited: Set = new Set(); + const queue: Array = []; + const indegrees: { [id: string]: number } = {}; + const vertices = Object.keys(graph); + + for (const v of vertices) { + indegrees[v] = indegree(graph, v); + } + + for (const i in graph) { + if (indegrees[i] === 0) { + queue.push(i); + visited.add(i); + } + } + + while (queue.length !== 0) { + const v = queue.shift(); + if (v === undefined) continue; + result.push(v); + for (const i in graph) { + if (graph[v]?.[i] !== 0 && !visited.has(i)) { + indegrees[i] = (indegrees[i] ?? 0) - (graph[v]?.[i] ?? 0); + if (indegrees[i] <= 0) { + queue.push(i); + visited.add(i); + } + } + } + } + + return result; +}; + +/** + * Flips the orientation of all edges in a directed graph. + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * graph = addEdge(graph, ["A", "B"]); + * //=> Graph { "A" -> "B", "C" } + * + * graph = addEdge(graph, ["B", "C"]); + * //=> Graph { "A" -> "B", "B" -> "C" } + * + * transpose(graph); + * //=> Graph { "B" -> "A", "C" -> "B" } + * ``` + */ +export const transpose = (graph: Graph): Graph => { + const result = {} as Graph; + const vertices = Object.keys(graph) as Array; + + for (const u of vertices) { + result[u] = {} as Graph[T]; + for (const v of vertices) { + result[u][v] = graph[v][u]; + } + } + + return result; +}; + +/** + * Returns the vertices in the graph. + */ +export const vertices = (graph: Graph): Set => + new Set(Object.keys(graph) as Array); + +/** + * Returns a list of all pairs of vertices in the graph irrespective of the edges present in the graph. + * + * @example + * ```js + * let graph = create(["A", "B", "C"]); + * //=> Graph { "A", "B", "C" } + * + * vertexPairs(graph); + * //=> Set { ["A", "A"], ["A", "B"], ["A", "C"], ["B", "B"], ["B", "C"], ["C", "C"] } + * ``` + */ +export const vertexPairs = (graph: Graph): Set> => { + const result: Set> = new Set(); + const vertices = Object.keys(graph) as Array; + + for (let u = 0; u < vertices.length; u++) { + for (let v = u; v < vertices.length; v++) { + result.add([vertices[u] as T, vertices[v] as T]); + } + } + + return result; +}; diff --git a/packages/graph-fns/src/isCyclic.test.ts b/packages/graph-fns/src/isCyclic.test.ts new file mode 100644 index 0000000..527cfb0 --- /dev/null +++ b/packages/graph-fns/src/isCyclic.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { isCyclic } from "../src"; + +describe("isCyclic", () => { + test("directed graph", () => { + expect(isCyclic({})).toBe(false); + + expect( + isCyclic({ + a: { a: 0 }, + }), + ).toBe(false); + + expect( + isCyclic({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }), + ).toBe(false); + + expect( + isCyclic({ + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }), + ).toBe(false); + + expect( + isCyclic({ + a: { a: 0, b: 1, c: 1 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toBe(false); + + expect( + isCyclic({ + a: { a: 1 }, + }), + ).toBe(true); + + expect( + isCyclic({ + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }), + ).toBe(true); + + expect( + isCyclic({ + a: { a: 0, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 1, b: 0, c: 0 }, + }), + ).toBe(true); + + expect( + isCyclic({ + a: { a: 1, b: 0 }, + b: { a: 0, b: 1 }, + }), + ).toBe(true); + }); + + test("undirected graph", () => { + expect(isCyclic({}, { undirected: true })).toBe(false); + + expect( + isCyclic( + { + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }, + { undirected: true }, + ), + ).toBe(false); + + expect( + isCyclic( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }, + { undirected: true }, + ), + ).toBe(false); + + expect( + isCyclic( + { + a: { a: 0, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 1, b: 1, c: 0 }, + }, + { undirected: true }, + ), + ).toBe(true); + + expect(() => { + isCyclic( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/isUndirected.test.ts b/packages/graph-fns/src/isUndirected.test.ts new file mode 100644 index 0000000..749cac6 --- /dev/null +++ b/packages/graph-fns/src/isUndirected.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { isUndirected } from "../src"; + +describe("isUndirected", () => { + test("base case", () => { + expect(isUndirected({})).toBe(true); + }); + + test("single vertex", () => { + expect(isUndirected({ a: { a: 1 } })).toBe(true); + }); + + test("undirected", () => { + expect( + isUndirected({ + a: { a: 1, b: 1 }, + b: { a: 0, b: 0 }, + }), + ).toBe(false); + }); + + test("directed", () => { + expect( + isUndirected({ + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }), + ).toBe(true); + }); +}); diff --git a/packages/graph-fns/src/makeUndirected.test.ts b/packages/graph-fns/src/makeUndirected.test.ts new file mode 100644 index 0000000..8cb6190 --- /dev/null +++ b/packages/graph-fns/src/makeUndirected.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { makeUndirected } from "../src"; + +describe("makeUndirected", () => { + test("base case", () => { + expect(makeUndirected({})).toEqual({}); + }); + + test("single vertex", () => { + expect(makeUndirected({ a: { a: 1 } })).toEqual({ a: { a: 1 } }); + }); + + test("unweighted edges", () => { + expect( + makeUndirected({ + a: { a: 1, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual({ + a: { a: 1, b: 1, c: 0 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }); + }); + + test("weighted edges", () => { + expect( + makeUndirected({ + a: { a: 0.5, b: -1, c: 0 }, + b: { a: 0, b: 0, c: -1 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual({ + a: { a: 0.5, b: -1, c: 0 }, + b: { a: -1, b: 0, c: -1 }, + c: { a: 0, b: -1, c: 0 }, + }); + }); + + test("custom merge function", () => { + expect( + makeUndirected( + { + a: { a: 2, b: 3, c: 0 }, + b: { a: 2, b: 0, c: 1 }, + c: { a: 0, b: 0, c: 0 }, + }, + (a, b) => a * b, + ), + ).toEqual({ + a: { a: 2, b: 6, c: 0 }, + b: { a: 6, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/order.test.ts b/packages/graph-fns/src/order.test.ts new file mode 100644 index 0000000..495baef --- /dev/null +++ b/packages/graph-fns/src/order.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { order } from "../src"; + +describe("order", () => { + test("base case", () => { + expect(order({})).toBe(0); + }); + + test("single node", () => { + expect(order({ a: { a: 0 } })).toBe(1); + }); + + test("two nodes", () => { + expect( + order({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }), + ).toBe(2); + }); +}); diff --git a/packages/graph-fns/src/outdegree.test.ts b/packages/graph-fns/src/outdegree.test.ts new file mode 100644 index 0000000..7f78b03 --- /dev/null +++ b/packages/graph-fns/src/outdegree.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { outdegree } from "../src"; + +describe("outdegree", () => { + test("unweighted", () => { + expect( + outdegree( + { + a: { a: 0, b: 2, c: 0.5 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }, + "a", + ), + ).toBe(2); + }); + + test("weighted", () => { + expect( + outdegree( + { + a: { a: 0, b: 2, c: 0.5 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }, + "a", + { weighted: true }, + ), + ).toBe(2.5); + }); +}); diff --git a/packages/graph-fns/src/parents.test.ts b/packages/graph-fns/src/parents.test.ts new file mode 100644 index 0000000..b6740c9 --- /dev/null +++ b/packages/graph-fns/src/parents.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { parents } from "../src"; + +describe("parents", () => { + test("basic", () => { + expect( + parents( + { + a: { a: 0, b: 0, c: 1 }, + b: { a: 0, b: 0, c: 0.5 }, + c: { a: 0, b: 0, c: 0 }, + }, + "c", + ), + ).toEqual(new Set(["a", "b"])); + }); + + test("loops should cause the vertex to be listed as a parent of itself", () => { + expect( + parents( + { + a: { a: 1 }, + }, + "a", + ), + ).toEqual(new Set(["a"])); + }); +}); diff --git a/packages/graph-fns/src/removeEdge.test.ts b/packages/graph-fns/src/removeEdge.test.ts new file mode 100644 index 0000000..aa5c0e9 --- /dev/null +++ b/packages/graph-fns/src/removeEdge.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { removeEdge } from "../src"; + +describe("removeEdge", () => { + test("basic", () => { + expect( + removeEdge( + { + a: { a: 0, b: 2 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + ), + ).toEqual({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }); + }); + + test("weighted edge", () => { + expect( + removeEdge( + { + a: { a: 0, b: 1.5, c: 0 }, + b: { a: 0, b: 0, c: 0.5 }, + c: { a: 2, b: 0, c: 0 }, + }, + ["a", "b"], + ), + ).toEqual({ + a: { a: 0, b: 0, c: 0 }, + b: { a: 0, b: 0, c: 0.5 }, + c: { a: 2, b: 0, c: 0 }, + }); + }); + + test("undirected", () => { + expect( + removeEdge( + { + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }, + ["a", "b"], + { undirected: true }, + ), + ).toEqual({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/removeVertex.test.ts b/packages/graph-fns/src/removeVertex.test.ts new file mode 100644 index 0000000..a67d846 --- /dev/null +++ b/packages/graph-fns/src/removeVertex.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { removeVertex } from "../src"; + +describe("removeVertex", () => { + test("basic", () => { + expect( + removeVertex( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 1, b: 0, c: 0 }, + }, + "b", + ), + ).toEqual({ + a: { a: 0, c: 0 }, + c: { a: 1, c: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/setEdge.test.ts b/packages/graph-fns/src/setEdge.test.ts new file mode 100644 index 0000000..86310b1 --- /dev/null +++ b/packages/graph-fns/src/setEdge.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { setEdge } from "../src"; + +describe("setEdge", () => { + test("set", () => { + expect( + setEdge( + { + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + 1, + ), + ).toEqual({ + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }); + }); + + test("unset", () => { + expect( + setEdge( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + 0, + ), + ).toEqual({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }); + }); + + test("set weighted", () => { + expect( + setEdge( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + 1.5, + ), + ).toEqual({ + a: { a: 0, b: 1.5 }, + b: { a: 0, b: 0 }, + }); + }); + + test("undirected", () => { + expect( + setEdge( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + 1.5, + { undirected: true }, + ), + ).toEqual({ + a: { a: 0, b: 1.5 }, + b: { a: 1.5, b: 0 }, + }); + }); +}); diff --git a/packages/graph-fns/src/size.test.ts b/packages/graph-fns/src/size.test.ts new file mode 100644 index 0000000..e47003e --- /dev/null +++ b/packages/graph-fns/src/size.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { size } from "../src"; + +describe("size", () => { + test("base case", () => { + expect(size({})).toBe(0); + }); + + test("basic", () => { + expect( + size({ + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }), + ).toBe(1); + }); + + test("loops", () => { + expect( + size({ + a: { a: 1 }, + }), + ).toBe(1); + }); + + test("weighted", () => { + expect( + size({ + a: { a: 1.8, b: 0, c: 0 }, + b: { a: 0, b: 0, c: 0.4 }, + c: { a: 1, b: 0, c: 0 }, + }), + ).toBe(3); + }); + + test("multiple", () => { + expect( + size({ + a: { a: 3, b: 0, c: 0 }, + b: { a: 0, b: 0, c: 2 }, + c: { a: 1, b: 0, c: 0 }, + }), + ).toBe(3); + }); + + test("undirected", () => { + expect( + size( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }, + { undirected: true }, + ), + ).toBe(2); + }); + + test("using the undirected option on a directed graph should throw an error", () => { + expect(() => { + size( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/toD3.test.ts b/packages/graph-fns/src/toD3.test.ts new file mode 100644 index 0000000..dc9f3c2 --- /dev/null +++ b/packages/graph-fns/src/toD3.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { toD3 } from "../src"; + +describe("toD3", () => { + test("basic", () => { + expect( + toD3({ + a: { a: 1, b: 2, c: 0.5 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual({ + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + links: [ + { source: "a", target: "a" }, + { source: "a", target: "b" }, + { source: "a", target: "b" }, + { source: "a", target: "c" }, + ], + }); + }); + + test("undirected", () => { + expect( + toD3( + { + a: { a: 0, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }, + { undirected: true }, + ), + ).toEqual({ + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + links: [ + { source: "a", target: "b" }, + { source: "a", target: "c" }, + ], + }); + }); + + test("using the undirected option on a directed graph should throw an error", () => { + expect(() => { + toD3( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/toDirected.test.ts b/packages/graph-fns/src/toDirected.test.ts new file mode 100644 index 0000000..832b2e9 --- /dev/null +++ b/packages/graph-fns/src/toDirected.test.ts @@ -0,0 +1,5 @@ +import { describe, test } from "bun:test"; + +describe("toDirected", () => { + test.skip("TODO", () => {}); +}); diff --git a/packages/graph-fns/src/topologicalSort.test.ts b/packages/graph-fns/src/topologicalSort.test.ts new file mode 100644 index 0000000..46d64b5 --- /dev/null +++ b/packages/graph-fns/src/topologicalSort.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { topologicalSort } from "../src"; + +describe("topologicalSort", () => { + test("base case", () => { + expect( + topologicalSort({ + a: { a: 0 }, + }), + ).toEqual(["a"]); + }); + + test('no edges { "A", "B", "C" }', () => { + expect( + topologicalSort({ + a: { a: 0, b: 0, c: 0 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual(["a", "b", "c"]); + }); + + test('basic { "C" -> "A", "A" -> "B" }', () => { + expect( + topologicalSort({ + a: { a: 0, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }), + ).toEqual(["c", "a", "b"]); + }); + + test('ambiguous order { "B" -> "A", "C" -> "A" }', () => { + expect( + topologicalSort({ + a: { a: 0, b: 0, c: 0 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }), + ).toEqual(["b", "c", "a"]); + }); + + test("weighted edges", () => { + expect( + topologicalSort({ + a: { a: 0, b: 0 }, + b: { a: 10.5, b: 0 }, + }), + ).toEqual(["b", "a"]); + }); + + test("cycles should throw an error", () => { + expect(() => { + topologicalSort({ + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }); + }).toThrowError(); + }); +}); diff --git a/packages/graph-fns/src/transpose.test.ts b/packages/graph-fns/src/transpose.test.ts new file mode 100644 index 0000000..34a3d62 --- /dev/null +++ b/packages/graph-fns/src/transpose.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { transpose } from "../src"; + +describe("transpose", () => { + test("base case", () => { + expect( + transpose({ + a: { a: 0 }, + }), + ).toEqual({ + a: { a: 0 }, + }); + }); + + test("single edge", () => { + expect( + transpose({ + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }), + ).toEqual({ + a: { a: 0, b: 0 }, + b: { a: 1, b: 0 }, + }); + }); + + test("weighted edge", () => { + expect( + transpose({ + a: { a: 0, b: -0.5 }, + b: { a: 0, b: 0 }, + }), + ).toEqual({ + a: { a: 0, b: 0 }, + b: { a: -0.5, b: 0 }, + }); + }); + + test("multiple edges", () => { + expect( + transpose({ + a: { a: 0, b: 1, c: 1 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual({ + a: { a: 0, b: 0, c: 0 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 1, c: 0 }, + }); + }); + + test("complete graph", () => { + expect( + transpose({ + a: { a: 1, b: 1, c: 1 }, + b: { a: 1, b: 1, c: 1 }, + c: { a: 1, b: 1, c: 1 }, + }), + ).toEqual({ + a: { a: 1, b: 1, c: 1 }, + b: { a: 1, b: 1, c: 1 }, + c: { a: 1, b: 1, c: 1 }, + }); + }); +}); diff --git a/packages/graph-fns/src/vertexPairs.test.ts b/packages/graph-fns/src/vertexPairs.test.ts new file mode 100644 index 0000000..fcd3bbe --- /dev/null +++ b/packages/graph-fns/src/vertexPairs.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { vertexPairs } from "../src"; + +describe("vertexPairs", () => { + test("basic", () => { + expect( + vertexPairs({ + a: { a: 0, b: 0, c: 0 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual( + new Set([ + ["a", "a"], + ["a", "b"], + ["a", "c"], + ["b", "b"], + ["b", "c"], + ["c", "c"], + ]), + ); + }); +}); diff --git a/packages/graph-fns/src/vertices.test.ts b/packages/graph-fns/src/vertices.test.ts new file mode 100644 index 0000000..4ecaea8 --- /dev/null +++ b/packages/graph-fns/src/vertices.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { vertices } from "../src"; + +describe("vertices", () => { + test("basic", () => { + expect( + vertices({ + a: { a: 0, b: 0, c: 0 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }), + ).toEqual(new Set(["a", "b", "c"])); + }); +});