diff --git a/site/docs/09-math/07-graph.mdx b/site/docs/09-math/07-graph.mdx new file mode 100644 index 00000000..5cd32e95 --- /dev/null +++ b/site/docs/09-math/07-graph.mdx @@ -0,0 +1,167 @@ +--- +title: Graph +slug: /graph +section: Math +--- + +## Graphs + +A powerful and flexible graph data structure implementation for working with connected data. This module provides a complete set of tools for creating, manipulating, and traversing graph structures with support for both directed and undirected weighted edges. + +## Overview + +The Graph module allows you to: + +- Create and manage nodes with custom data +- Connect nodes with weighted, directed or undirected edges +- Position nodes in 2D space for spatial algorithms +- Perform common graph traversal operations like BFS and DFS +- Find optimal paths using Dijkstra's algorithm or A* search + +## Basic Usage + +### Creating a Graph and working with Nodes and Edges + +```ts +import { Graph } from 'excalibur'; + +// Create an empty graph of strings +const graph = new Graph(); + +// Add a few nodes with string data +const nodeA = graph.addNode("A"); +const nodeB = graph.addNode("B"); +const nodeC = graph.addNode("C"); + +// Connect nodes with bidirectional edges (default) +graph.addEdge(nodeA, nodeB); +graph.addEdge(nodeB, nodeC); +graph.addEdge(nodeC, nodeD); +graph.addEdge(nodeD, nodeE); + +// Connect nodes in one direction only +graph.addEdge(nodeA, nodeC, { directed: true }); + +// Connect nodes with weighted edges +graph.addEdge(nodeA, nodeB, { weight: 5 }); + +// Use coordinates of parent nodes to dictate weighting +graph.addEdge(nodeA, nodeB, {useEuclidean: true}); + +// Check if nodes are connected +const connected = graph.areNodesConnected(nodeA, nodeB); // true + +// Get neighbors of a node +const neighbors = graph.getNeighbors(nodeA); // [nodeB] + +// Delete a node (and its edges) +graph.deleteNode(nodeC); + +// Delete an edge +graph.deleteEdge(edges[0]); +``` + +## Core Concepts + +### Node Types + +The Graph module supports two node types: + +Node: Basic graph node with data + +PositionNode: Node with 2D spatial coordinates, uses Excalibur's Native Vector type for position + +```ts +// Add positioned nodes, when Vector positions are attached to nodes, it returns a PositionNode +const nodeA = graph.addNode("A", new Vector(0, 0)); +const nodeB = graph.addNode("B", new Vector(5, 10)); +const nodeC = graph.addNode("C", new Vector(10, 5)); +``` + +### Edge Options + +The optional third parameter for creating an Edge has this interface (in principle): + +```ts +interface EdgeOptions = { + weighted: number + useEuclidean: boolean + direction: boolean +} +``` + +It is setup as a discriminated union to allow: + +- passing a basic weighted value to an edge +- directing the edge to use the positions of its parent nodes to calculate its weighting +- or use no weighting at all + +You cannot pass a weighting parameter and direct the Edge to use Euclidean positions of the nodes, you have to pass one or the other. + +### Edge Properties + +Edges connect nodes and can have properties: + +weight: Numeric value representing distance or cost (default: 0) +directed: Whether the edge is one-way or bidirectional (default: true) +partner: reference to a paired edge if a bidirectional edge was created + +```ts +// Connect the nodes +spatialGraph.addEdge(nodeA, nodeB, { weight: 11.2, directed: false }); // Euclidean distance + +``` + +### Graph Traversal + +#### Breadth-First Search (BFS) + +Explore the graph layer by layer, visiting all direct neighbors before moving deeper: + +```ts +// Create and populate your graph first +const visitedNodeIds = graph.bfs(startNode); +``` + +#### Depth-First Search (DFS) + +Explore the graph by moving as far as possible along each branch before backtracking: + +```ts +// Create and populate your graph first +const visitedNodeIds = graph.dfs(startNode); +``` + +### Pathfinding Algorithms + +#### Shortest Path and Dijkstra's Algorithm + +Find the shortest path between two nodes in a weighted graph: + +```ts +// Find shortest path from A to C +const { path, distance } = graph.shortestPathDijkstra(nodeA, nodeC); + +// Get full analysis +const dijkstraAnalysis = graph.dijkstra(nodeA); +``` + +#### A* Algorithm + +Find the shortest path using spatial information for better performance: + +```ts +const aStarResults = graph.astar(nodeA, nodeB); +``` +Returns an object with a list of nodes representing the path, a number representing the number of node steps required, the overall distance traversed, and if any nodes were skipped due to them not being PositionNodes. + +## Other Features + +### Building a Graph from Data Arrays + +For convenience, you can create a graph from arrays of node data: +```ts +// Create a graph with string data nodes +const cities = ["New York", "London", "Tokyo", "Sydney", "Paris"]; +const graph = Graph.createGraphFromNodes(cities); +``` \ No newline at end of file diff --git a/src/engine/Math/graph.ts b/src/engine/Math/graph.ts new file mode 100644 index 00000000..8a68b7b4 --- /dev/null +++ b/src/engine/Math/graph.ts @@ -0,0 +1,745 @@ +import { Random } from './Random'; +import type { Vector } from './vector'; + +/** + * A unique identifier for a graph node or edge. + */ +export type G_UUID = string & { readonly __brand: unique symbol }; + +interface EdgeOptionsWithWeight { + weight: number; + useEuclidean?: false; + /** + * Whether the edge is directed. + * @default false + */ + directed?: boolean; +} + +interface EdgeOptionsWeightless { + weight?: undefined; + useEuclidean?: false | undefined; + /** + * Whether the edge is directed. + * @default false + */ + directed?: boolean; +} + +interface EdgeOptionsWithEuclidean { + weight?: undefined; + useEuclidean: true; + /** + * Whether the edge is directed. + * @default false + */ + directed?: boolean; +} + +/** + * Options for creating a new edge in the graph. + */ +type EdgeOptions = EdgeOptionsWithWeight | EdgeOptionsWithEuclidean | EdgeOptionsWeightless; + +/** + * A weighted graph data structure. + * @template T The type of data stored in each node. + */ +export class Graph { + private _nodes: Map>; + private _edges: Set>; + adjacencyList: Map>; + id: G_UUID = GraphUUId.generateUUID(); + + /** + * Constructs a new graph data structure. + * + * This constructor initializes an empty graph with no nodes or edges. + */ + constructor() { + this._nodes = new Map(); + this._edges = new Set(); + this.adjacencyList = new Map(); + } + + /** + * Adds a new node to the graph with the given data. + * @returns The newly created node. + */ + addNode(data: T, position?: Vector): Node | PositionNode { + let newNode; + if (position) { + newNode = new PositionNode(data, position); + } else { + newNode = new Node(data); + } + this._nodes.set(newNode.id, newNode); + this.adjacencyList.set(newNode.id, new Set()); + return newNode; + } + + /** + * Adds multiple new nodes to the graph with the given data. + * @returns A map of all nodes in the graph, including the newly created ones. + */ + addNodes(nodes: T[]): Map> { + for (const node of nodes) { + const thisNewNode = new Node(node); + this._nodes.set(thisNewNode.id, thisNewNode); + this.adjacencyList.set(thisNewNode.id, new Set()); + } + return this._nodes; + } + + /** + * Deletes a node from the graph along with all its associated edges. + * This method removes the specified node and any edges connected to it + * from the graph. It updates the internal structures to reflect these + * changes. + * @param node - The node to be deleted from the graph. + * @returns A map of all remaining nodes in the graph. + */ + deleteNode(node: Node): Map> { + //delete any edges tied to node + const nodeEdges = node.edges; + for (const edge of nodeEdges) { + this.deleteEdge(edge); + } + + this.adjacencyList.forEach((value, key) => { + value.delete(node.id); + }); + + this._nodes.delete(node.id); + this.adjacencyList.delete(node.id); + return this._nodes; + } + + /** + * Adds a new edge between two nodes in the graph. If the edge already exists, it does not add a duplicate. + * The function allows specifying edge options such as weight and directionality. For undirected edges, + * it creates a duplicate edge in the reverse direction and links both edges as partners. + * @param from - The source node of the edge. + * @param to - The target node of the edge. + * @param options - Optional settings for the edge, including weight and directionality. + * @returns An array containing the created edge(s). If the edge is directed, the array contains one edge; + * if undirected, it contains both the original and the duplicate edge. + */ + + addEdge(from: Node, to: Node, options?: EdgeOptions): Edge[] { + //gaurd clauses + const existingEdges = Array.from(this._edges).find((edge) => edge.source.id === from.id && edge.target.id === to.id); + if (existingEdges) { + return []; + } + + let directed; + + if (options) { + directed = 'directed' in options ? options.directed : false; + } else { + directed = false; + } + + const newEdge = new Edge(from, to, options); + + this._edges.add(newEdge); + from.registerNewEdge(newEdge); + to.registerNewEdge(newEdge); + this.adjacencyList.get(from.id)?.add(to.id); + + if (!directed) { + const duplicateEdge = new Edge(to, from, options); + this.adjacencyList.get(to.id)?.add(from.id); + this._edges.add(duplicateEdge); + to.registerNewEdge(duplicateEdge); + from.registerNewEdge(duplicateEdge); + //link the two edges together + newEdge.linkWithPartner(duplicateEdge); + duplicateEdge.linkWithPartner(newEdge); + return [newEdge, duplicateEdge]; + } + return [newEdge]; + } + + /** + * Deletes an edge from the graph. + * + * This method removes the specified edge and its partner edge (if any) from the graph. + * It updates the internal edge set and edge list accordingly. The source and target + * nodes of the edge are also updated to reflect the removal of the edge. + * @param edge - The edge to be deleted from the graph. + */ + + deleteEdge(edge: Edge) { + edge.source.breakEdge(edge); + edge.target.breakEdge(edge); + this._edges.delete(edge); + + const partnerEdge = edge.partnerEdge; + if (partnerEdge) { + partnerEdge.source.breakEdge(partnerEdge); + partnerEdge.target.breakEdge(partnerEdge); + this._edges.delete(partnerEdge); + } + } + + /** + * The set of nodes in the graph, keyed by their UUID. + * + * The map returned by this property is a shallow copy of the internal map. + * The nodes in this map are not frozen, and may be modified by the caller. + * @returns A shallow copy of the graph's internal node map. + */ + get nodes(): Map> { + return this._nodes; + } + + /** + * Gets a node by its UUID. + * @param id - The UUID of the node to be retrieved. + * @returns The node with the specified UUID, or undefined if no such node exists. + */ + getNode(id: G_UUID): Node { + return this._nodes.get(id)!; + } + + /** + * Retrieves the set of edges in the graph. + * + * The returned set is a shallow copy of the internal edge set. + * Modifications to this set do not affect the graph's internal state. + * @returns A set containing all edges in the graph. + */ + get edges(): Set> { + return this._edges; + } + + /** + * Gets the neighbors of the given node. + * + * The returned array contains all of the nodes that are directly connected to the given node. + * @param node - The node whose neighbors should be retrieved. + * @returns An array of nodes that are directly connected to the given node. + */ + getNeighbors(node: Node): Node[] { + return Array.from(this.adjacencyList.get(node.id) ?? []).map((nid) => this.nodes.get(nid)!); + } + + /** + * Checks if two nodes are connected by an edge. + * @param node1 - The first node to check. + * @param node2 - The second node to check. + * @returns true if the nodes are connected, false if not. + */ + areNodesConnected(node1: Node, node2: Node): boolean { + return this.adjacencyList.get(node1.id)?.has(node2.id) ?? false; + } + + /** + * Performs a breadth-first search (BFS) on the graph starting from the given node. + * + * This method explores the graph layer by layer, starting from the specified node. + * It visits all nodes that are directly connected to the start node before moving + * on to the nodes at the next level of the graph. + * @param startNode - The node to start the BFS from. + * @returns An array of UUIDs representing the nodes that were visited during the search. + * The order of the nodes in the array corresponds to the order in which they + * were visited. + */ + bfs(startNode: Node): G_UUID[] { + // Verify the start node exists in the graph + if (!this._nodes.has(startNode.id)) { + return []; + } + + const queue: G_UUID[] = [startNode.id]; + const visited: Set = new Set([startNode.id]); + + while (queue.length > 0) { + const nodeId = queue.shift()!; + const neighbors = this.adjacencyList.get(nodeId) || new Set(); + for (const neighborId of neighbors) { + if (!visited.has(neighborId)) { + visited.add(neighborId); + queue.push(neighborId); + } + } + } + return Array.from(visited); + } + + /** + * Performs a depth-first search (DFS) on the graph starting from the given node. + * + * This method explores the graph by traversing as far as possible along each + * branch before backtracking. It visits all nodes that are reachable from the + * start node. + * @param startNode - The node to start the DFS from. + * @param [visited] - A set of node IDs that have already been visited during + * the search. This parameter is optional, and defaults to an + * empty set. + * @returns An array of UUIDs representing the nodes that were visited during the + * search. The order of the nodes in the array corresponds to the order + * in which they were visited. + */ + dfs(startNode: Node, visited: Set = new Set()): G_UUID[] { + const startId: G_UUID = startNode.id; + if (!this._nodes.has(startId)) { + return []; + } // Invalid start node + + visited.add(startId); + let result: G_UUID[] = [startId]; + + for (const neighbor of this.adjacencyList.get(startId) ?? []) { + if (!visited.has(neighbor)) { + result = result.concat(this.dfs(this._nodes.get(neighbor)!, visited)); + } + } + return result; + } + + /** + * Creates a new graph from an array of nodes, and adds them all to the graph. + * @param nodes - The array of nodes to add to the graph. + * @returns The newly created graph. + */ + static createGraphFromNodes(nodes: T[]): Graph { + const graph = new Graph(); + graph.addNodes(nodes); + return graph; + } + + /** + * Finds the shortest path between two nodes in the graph using Dijkstra's algorithm. + * + * This method calculates the shortest path from the specified start node to the + * specified end node in the graph. It returns an object containing the path and + * the total distance of the path. + * @param startNode - The node from which the search for the shortest path begins. + * @param endNode - The node where the search for the shortest path ends. + * @returns An object containing: + * - `path`: An array of nodes representing the shortest path from startNode to endNode. + * If no path is found, this will be `null`. + * - `distance`: The total distance of the shortest path. If no path is found, this will + * be `Infinity`. + */ + + dijkstra(sourcenode: Node): Array<{ node: Node; distance: number; previous: Node | null }> { + const visited: Node[] = []; + const unvisited: Node[] = []; + const resultArray: Array<{ node: Node; distance: number; previous: Node | null }> = []; + + //fill unvisited + this.nodes.forEach((node) => unvisited.push(node)); + + //fill resultArray + this.nodes.forEach((node) => resultArray.push({ node, distance: Infinity, previous: null })); + + //start with starting node + //add startingnode to result array + const startingNodeIndex = resultArray.findIndex((node) => node.node === sourcenode); + if (startingNodeIndex === -1) { + return []; + } + resultArray[startingNodeIndex].distance = 0; + + visited.push(sourcenode); + unvisited.splice(unvisited.indexOf(sourcenode), 1); + + let current = sourcenode; + const currentEdges = current.edges; + const filteredCurrentEdges: Edge[] = Array.from(currentEdges).filter((edge: Edge) => edge.target !== current); + + //update result array with distances, which is edge values + + for (const edge of filteredCurrentEdges) { + const index = resultArray.findIndex((node) => node.node === edge.target); + + if (index === -1) { + return []; + } + resultArray[index].distance = edge.weight as number; + resultArray[index].previous = current; + } + + while (unvisited.length > 0) { + //get list of unvisited available nodes + let listOfAvailableNodes: Node[] = []; + let listofAvailableEntries: Array<{ node: Node; distance: number; previous: Node | null }> = []; + listofAvailableEntries = resultArray.filter((node) => unvisited.includes(node.node)); + listOfAvailableNodes = listofAvailableEntries.map((node) => node.node); + + //loop through available nodes and find lowest distance to sourcenode + let lowestDistance = Infinity; + let lowestDistanceIndex = -1; + + if (listOfAvailableNodes.length > 0) { + for (let i = 0; i < listOfAvailableNodes.length; i++) { + const unVisitiedNode = listOfAvailableNodes[i]; + + const index = resultArray.findIndex((node) => node.node === unVisitiedNode); + if (resultArray[index].distance < lowestDistance) { + lowestDistance = resultArray[index].distance; + lowestDistanceIndex = index; + } + } + } else { + //manage exception + //choose node from unvisited list that has lowest distance to source node + + lowestDistance = Infinity; + lowestDistanceIndex = -1; + for (let i = 0; i < unvisited.length; i++) { + const unVisitiedNode = unvisited[i]; + const index = resultArray.findIndex((node) => node.node === unVisitiedNode); + if (resultArray[index].distance < lowestDistance) { + lowestDistance = resultArray[index].distance; + lowestDistanceIndex = index; + } + } + } + + if (lowestDistanceIndex === -1) { + return []; + } + + current = resultArray[lowestDistanceIndex].node; + let currentEdgesArray = Array.from(current.edges); + + //remove visited from currentEdges + currentEdgesArray = currentEdgesArray.filter((edge: Edge) => { + return !visited.includes(edge.source) && !visited.includes(edge.target) && edge.target !== current; + }); + + visited.push(current); + unvisited.splice(unvisited.indexOf(current), 1); + + //update result array with distances, which is edge values + for (let i = 0; i < currentEdgesArray.length; i++) { + const edge = currentEdgesArray[i]; + const index = resultArray.findIndex((node) => node.node === edge.target); + + //update cumulative distances + const previousIndex = resultArray.findIndex((node) => node.node === edge.source); + + const previousDistance = resultArray[previousIndex].distance; + const cumDistance = (previousDistance + edge.weight!) as number; + + if (cumDistance < resultArray[index].distance) { + resultArray[index].distance = cumDistance; + resultArray[index].previous = current; + } + } + } + + return resultArray; + } + + /** + * Finds the shortest path between two nodes in the graph using the Dijkstra method + * + * This method calculates the shortest path from the specified start node to the + * specified end node in the graph. It returns an object containing the path and + * the total distance of the path. + * @param startingNode - The node from which the search for the shortest path begins. + * @param endNode - The node where the search for the shortest path ends. + * @returns An object containing: + * - `path`: An array of nodes representing the shortest path from startNode to endNode. + * If no path is found, this will be `null`. + * - `distance`: The total distance of the shortest path. If no path is found, this will + * be `Infinity`. + */ + shortestPathDijkstra(startingNode: Node, endNode: Node): { path: Node[]; distance: number } { + const dAnalysis = this.dijkstra(startingNode); + + if (dAnalysis.length === 0) { + return { path: [], distance: Infinity }; + } + //iterate through dAnalysis to plot shortest path to endnode + const path: Node[] = []; + let current: Node | null | undefined = endNode; + const distance = dAnalysis.find((node) => node.node === endNode)?.distance as number; + + while (current != null) { + path.push(current); + + current = dAnalysis.find((node) => node.node === current)?.previous; + + if (current == null) { + break; + } + } + path.reverse(); + return { path, distance }; + } + + /** + * Finds the shortest path between two nodes in the graph using the A* algorithm. + * + * This method calculates the shortest path from the specified start node to the + * specified end node in the graph. It returns an object containing the path and + * the total distance of the path. + * @param startNode - The node from which the search for the shortest path begins. + * @param endNode - The node where the search for the shortest path ends. + * @returns An object containing: + * - `path`: An array of nodes representing the shortest path from startNode to endNode. + * If no path is found, this will be `null`. + * - `distance`: The total distance of the shortest path. If no path is found, this will + * be `Infinity`. + * - `skippedNodes`: A set of all nodes that were skipped during the search (because they + * were not `PositionNode`s). + */ + aStar( + startNode: PositionNode, + endNode: PositionNode + ): { + path: PositionNode[] | null; + pathSteps: number; + distance: number; + skippedNodes: Set; + } { + // Make sure we're working with PositionNodes + if (!('pos' in startNode) || !('pos' in endNode)) { + throw new Error('A* algorithm requires PositionNode with position vectors'); + } + + // Initialize data structures + const openSet: Set = new Set([startNode.id]); + const closedSet: Set = new Set(); + const skippedNodes: Set = new Set(); + + // Track g scores (distance from start) and f scores (estimated total cost) + const gScore: Map = new Map(); + const hScore: Map = new Map(); + const fScore: Map = new Map(); + + // Track the path + const cameFrom: Map = new Map(); + + // Initialize scores + + //remap positionNodes from this._nodes where node is type of PositionNode + const positionNodes: Map> = new Map(); + for (const [nodeId, node] of this._nodes) { + if ('pos' in node) { + positionNodes.set(nodeId, node as PositionNode); + } else { + skippedNodes.add(nodeId); + } + } + + for (const [nodeId] of positionNodes) { + gScore.set(nodeId, this._euclideanDistance(positionNodes.get(nodeId)!, startNode)); + hScore.set(nodeId, this._euclideanDistance(positionNodes.get(nodeId)!, endNode)); + fScore.set(nodeId, gScore.get(nodeId)! + hScore.get(nodeId)!); + cameFrom.set(nodeId, null); + } + + // Continue until we've visited all nodes or found the target + while (openSet.size > 0) { + // Find node with lowest fScore + let currentId: G_UUID | null = null; + let lowestFScore = Infinity; + + for (const nodeId of openSet) { + const score = fScore.get(nodeId) || Infinity; + if (score < lowestFScore) { + lowestFScore = score; + currentId = nodeId; + } + } + + // If we can't find a node, there's no path + if (currentId === null) { + break; + } + + // If we found the target, we're done + if (currentId === endNode.id) { + // Reconstruct path + const path: PositionNode[] = []; + let current: G_UUID | null = endNode.id; + + while (current !== null) { + const node = this._nodes.get(current)! as PositionNode; + path.unshift(node); + current = cameFrom.get(current)!; + } + + return { + path, + pathSteps: path.length - 1, + distance: gScore.get(endNode.id) || Infinity, + skippedNodes + }; + } + + // Move from open to closed set + openSet.delete(currentId); + closedSet.add(currentId); + + // Get current node + const currentNode = this._nodes.get(currentId)! as PositionNode; + + // Check all neighbors + const neighbors = this.getNeighbors(currentNode); + + for (const neighbor of neighbors) { + const neighborId = neighbor.id; + + // Skip if neighbor has been processed + if (closedSet.has(neighborId)) { + continue; + } + + // Ensure neighbor is a PositionNode + const positionNeighbor = neighbor as PositionNode; + if (!('pos' in positionNeighbor)) { + continue; + } + + // Find the edge connecting current to neighbor + const edge: Edge = Array.from(currentNode.edges).find((e: Edge) => e.source.id === currentId && e.target.id === neighborId); + + if (!edge) { + continue; + } + + cameFrom.set(neighborId, currentId); + // Add to open set if not already there + if (!openSet.has(neighborId)) { + openSet.add(neighborId); + } + } + } + + // No path found + return { path: [], pathSteps: 0, distance: Infinity, skippedNodes }; + } + + private _euclideanDistance(currentNode: PositionNode, testNode: PositionNode): number { + const a = currentNode.pos; + const b = testNode.pos; + return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); + } +} + +/** + * Represents an edge in a graph, connecting two nodes. + * @template T The type of data stored in the nodes connected by this edge. + */ +export class Edge { + private _id: G_UUID = GraphUUId.generateUUID(); + private _source: Node; + private _target: Node; + private _weight: number = 0; + private _partnerEdge: Edge | null = null; // Reference to the opposite direction edge + + constructor(source: Node, target: Node, config?: EdgeOptions) { + this._source = source; + this._target = target; + if (config && config.weight) { + this._weight = config.weight; + } else if (config && config.useEuclidean) { + this._weight = (source as PositionNode).pos.distance((target as PositionNode).pos); //calc weight + } else { + this._weight = 0; + } + } + + linkWithPartner(partnerEdge: Edge): void { + this._partnerEdge = partnerEdge; + } + + get id() { + return this._id; + } + + get source() { + return this._source; + } + + get target() { + return this._target; + } + + get weight() { + return this._weight; + } + + get partnerEdge() { + return this._partnerEdge; + } +} + +/** + * Represents a node in a graph, with a unique identifier and optional data. + * @template T The type of data stored in this node. + */ +export class Node { + private _id: G_UUID = GraphUUId.generateUUID(); + private _data: T; + private _edges: Set>; + + constructor(data: T) { + this._data = data; + this._edges = new Set(); + } + + get id(): G_UUID { + return this._id; + } + + get data() { + return this._data; + } + + get edges() { + return this._edges; + } + + registerNewEdge(newEdge: Edge) { + this._edges.add(newEdge); + } + + breakEdge(edge: Edge) { + this._edges.delete(edge); + } + + getConnectedNodes(): Node[] { + return Array.from(this._edges).map((edge) => edge.target); + } +} + +/** + * Represents a node in a graph with a unique identifier, optional data, and a position in space. + * @template T The type of data stored in this node. + * @augments {Node} + */ +export class PositionNode extends Node { + pos: Vector; + + constructor(data: T, pos: Vector) { + super(data); + this.pos = pos; + } +} + +class GraphUUId { + static rng: Random = new Random(); + + static generateUUID(): G_UUID { + const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; + + const generatedUuid = uuid.replace(/[xy]/g, function (c) { + const r = (GraphUUId.rng.next() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + + // Type assertion to convert string to branded type + return generatedUuid as G_UUID; + } +} diff --git a/src/engine/Math/index.ts b/src/engine/Math/index.ts index 28fd9231..bb2c87d7 100644 --- a/src/engine/Math/index.ts +++ b/src/engine/Math/index.ts @@ -13,3 +13,4 @@ export * from './lerp'; export * from './bezier-curve'; export * from './util'; export * from './rotation-type'; +export * from './graph'; diff --git a/src/spec/vitest/GraphSpec.ts b/src/spec/vitest/GraphSpec.ts new file mode 100644 index 00000000..c95715f3 --- /dev/null +++ b/src/spec/vitest/GraphSpec.ts @@ -0,0 +1,394 @@ +import * as ex from '@excalibur'; + +describe('A Graph', () => { + let graph: ex.Graph; + + beforeEach(() => { + graph = new ex.Graph(); + }); + + it('can exist', () => { + expect(ex.Graph).toBeDefined(); + }); + + describe('Node Operations', () => { + it('can add a node', () => { + const node = graph.addNode('test'); + expect(node).toBeInstanceOf(ex.Node); + expect(node.data).toBe('test'); + expect(graph.nodes.size).toBe(1); + expect(graph.adjacencyList.has(node.id)).toBe(true); + }); + + it('should add multiple nodes to the graph', () => { + const nodes = graph.addNodes(['A', 'B', 'C']); + expect(nodes.size).toBe(3); + expect(graph.adjacencyList.size).toBe(3); + }); + + it('should delete a node from the graph', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + graph.addEdge(nodeA, nodeB); + + const remainingNodes = graph.deleteNode(nodeA); + expect(remainingNodes.size).toBe(1); + expect(graph.nodes.has(nodeA.id)).toBe(false); + expect(graph.adjacencyList.has(nodeA.id)).toBe(false); + expect(graph.edges.size).toBe(0); + }); + + it('should get a node by its id', () => { + const node = graph.addNode('A'); + const retrievedNode = graph.getNode(node.id); + expect(retrievedNode).toBe(node); + }); + }); + + describe('Edge operations', () => { + it('should add a directed edge between two nodes', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + const edges = graph.addEdge(nodeA, nodeB, { weight: 5, directed: true }); + + expect(edges.length).toBe(1); + expect(edges[0].source).toBe(nodeA); + expect(edges[0].target).toBe(nodeB); + expect(edges[0].weight).toBe(5); + expect(graph.adjacencyList.get(nodeA.id)?.has(nodeB.id)).toBe(true); + expect(graph.adjacencyList.get(nodeB.id)?.has(nodeA.id)).toBe(false); + }); + + it('should add an undirected edge between two nodes', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + const edges = graph.addEdge(nodeA, nodeB, { weight: 5, directed: false }); + + expect(edges.length).toBe(2); + expect(graph.adjacencyList.get(nodeA.id)?.has(nodeB.id)).toBe(true); + expect(graph.adjacencyList.get(nodeB.id)?.has(nodeA.id)).toBe(true); + + // Check partner edges are linked + expect(edges[0].partnerEdge).toBe(edges[1]); + expect(edges[1].partnerEdge).toBe(edges[0]); + }); + + it('should not add duplicate edges', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + graph.addEdge(nodeA, nodeB, { directed: true }); + const duplicateEdges = graph.addEdge(nodeA, nodeB, { directed: true }); + + expect(duplicateEdges.length).toBe(0); + expect(graph.edges.size).toBe(1); + }); + + it('should delete an edge from the graph', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + const edges = graph.addEdge(nodeA, nodeB, { directed: true }); + expect(graph.edges.size).toBe(1); + + graph.deleteEdge(edges[0]); + expect(graph.edges.size).toBe(0); + expect(nodeA.edges.size).toBe(0); + expect(nodeB.edges.size).toBe(0); + }); + + it('should delete an undirected edge and its partner', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + const edges = graph.addEdge(nodeA, nodeB, { weight: 5, directed: false }); + expect(graph.edges.size).toBe(2); + + graph.deleteEdge(edges[0]); + expect(graph.edges.size).toBe(0); + }); + }); + + describe('Graph queries', () => { + it('should get neighbors of a node', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + const nodeC = graph.addNode('C'); + + graph.addEdge(nodeA, nodeB); + graph.addEdge(nodeA, nodeC); + + const neighbors = graph.getNeighbors(nodeA); + expect(neighbors.length).toBe(2); + expect(neighbors).toContain(nodeB); + expect(neighbors).toContain(nodeC); + }); + + it('should check if nodes are connected', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + const nodeC = graph.addNode('C'); + + graph.addEdge(nodeA, nodeB, { directed: true }); + + expect(graph.areNodesConnected(nodeA, nodeB)).toBe(true); + expect(graph.areNodesConnected(nodeA, nodeC)).toBe(false); + expect(graph.areNodesConnected(nodeB, nodeA)).toBe(false); // Directed edge + }); + }); + + describe('traversal', () => { + it('should perform breadth-first search (BFS)', () => { + // Create a simple graph + // A -> B -> D + // | | + // v v + // C -> E + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + const nodeC = graph.addNode('C'); + const nodeD = graph.addNode('D'); + const nodeE = graph.addNode('E'); + + graph.addEdge(nodeA, nodeB); + graph.addEdge(nodeA, nodeC); + graph.addEdge(nodeB, nodeD); + graph.addEdge(nodeB, nodeE); + graph.addEdge(nodeC, nodeE); + + const visited = graph.bfs(nodeA); + + // BFS should visit level by level + // Could be A, B, C, D, E or A, B, C, E, D depending on implementation + expect(visited.length).toBe(5); + expect(visited[0]).toBe(nodeA.id); + // Check that B and C are visited before D and E + const indexB = visited.indexOf(nodeB.id); + const indexC = visited.indexOf(nodeC.id); + const indexD = visited.indexOf(nodeD.id); + const indexE = visited.indexOf(nodeE.id); + + expect(indexB).toBeLessThan(indexD); + expect(indexB).toBeLessThan(indexE); + expect(indexC).toBeLessThan(indexE); + }); + + it('should perform depth-first search (DFS)', () => { + // Create a simple graph + // A -> B -> D + // | | + // v v + // C -> E + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + const nodeC = graph.addNode('C'); + const nodeD = graph.addNode('D'); + const nodeE = graph.addNode('E'); + + graph.addEdge(nodeA, nodeB); + graph.addEdge(nodeA, nodeC); + graph.addEdge(nodeB, nodeD); + graph.addEdge(nodeB, nodeE); + graph.addEdge(nodeC, nodeE); + + const visited = graph.dfs(nodeA); + + expect(visited.length).toBe(5); + expect(visited[0]).toBe(nodeA.id); + + // Check that one path is fully explored before backtracking + // The exact ordering depends on the DFS implementation and how neighbors are processed + }); + + it('should return empty array for BFS with invalid start node', () => { + const nodeA = graph.addNode('A'); + const invalidNode = new ex.Node('Invalid'); + + expect(graph.bfs(invalidNode)).toEqual([]); + }); + + it('should return empty array for DFS with invalid start node', () => { + const nodeA = graph.addNode('A'); + const invalidNode = new ex.Node('Invalid'); + + expect(graph.dfs(invalidNode)).toEqual([]); + }); + }); + + describe('Static graph creation', () => { + it('should create a graph from nodes', () => { + const newGraph = ex.Graph.createGraphFromNodes(['A', 'B', 'C']); + expect(newGraph).toBeInstanceOf(ex.Graph); + expect(newGraph.nodes.size).toBe(3); + }); + }); + + describe('Path finding algorithms', () => { + it('should create a Djikstra analysis of nodeA', () => { + // Create a weighted graph + // 5 + // A --- B + // | | + // 2| |1 + // | | + // C --- D + // 8 + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + const nodeC = graph.addNode('C'); + const nodeD = graph.addNode('D'); + + graph.addEdge(nodeA, nodeB, { weight: 5 }); + graph.addEdge(nodeA, nodeC, { weight: 2 }); + graph.addEdge(nodeB, nodeD, { weight: 1 }); + graph.addEdge(nodeC, nodeD, { weight: 8 }); + + const result = graph.dijkstra(nodeA); + + expect(result.length).toBe(4); + expect(result[0].node).toBe(nodeA); + expect(result[0].distance).toBe(0); + expect(result[1].node).toBe(nodeB); + expect(result[1].distance).toBe(5); + expect(result[2].node).toBe(nodeC); + expect(result[2].distance).toBe(2); + expect(result[3].node).toBe(nodeD); + expect(result[3].distance).toBe(6); + }); + + it('should find shortest path between two nodes', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + const nodeC = graph.addNode('C'); + const nodeD = graph.addNode('D'); + const nodeE = graph.addNode('E'); + + //add edges + + graph.addEdge(nodeA, nodeB, { weight: 5 }); + graph.addEdge(nodeA, nodeC, { weight: 2 }); + graph.addEdge(nodeB, nodeD, { weight: 1 }); + graph.addEdge(nodeC, nodeD, { weight: 8 }); + graph.addEdge(nodeD, nodeE, { weight: 3 }); + + // Find shortest path between A and E + const result = graph.shortestPathDijkstra(nodeA, nodeE); + + expect(result.path.length).toBe(4); + expect(result.path[0]).toBe(nodeA); + expect(result.path[1]).toBe(nodeB); + expect(result.path[2]).toBe(nodeD); + expect(result.path[3]).toBe(nodeE); + expect(result.distance).toBe(9); + }); + + it('should return empty path when no path exists', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + // No edge connecting A and B + const result = graph.dijkstra(nodeA); + + expect(result.length).toBe(0); + }); + + it('should handle zero-distance path (same node)', () => { + const nodeA = graph.addNode('A'); + + const result = graph.dijkstra(nodeA); + + expect(result.length).toBe(1); + expect(result[0].node).toBe(nodeA); + expect(result[0].distance).toBe(0); + }); + + it('should find shortest path between two nodes', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + const nodeC = graph.addNode('C'); + const nodeD = graph.addNode('D'); + const nodeE = graph.addNode('E'); + + graph.addEdge(nodeA, nodeB, { weight: 5 }); + graph.addEdge(nodeA, nodeC, { weight: 2 }); + graph.addEdge(nodeB, nodeD, { weight: 1 }); + graph.addEdge(nodeC, nodeD, { weight: 8 }); + graph.addEdge(nodeD, nodeE, { weight: 3 }); + + const result = graph.shortestPathDijkstra(nodeA, nodeE); + expect(result.path.length).toBe(4); + expect(result.path[0]).toBe(nodeA); + expect(result.path[1]).toBe(nodeB); + expect(result.path[2]).toBe(nodeD); + expect(result.path[3]).toBe(nodeE); + expect(result.distance).toBe(9); + }); + + it('should return empty path when no path exists', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + const result = graph.shortestPathDijkstra(nodeA, nodeB); + expect(result.path.length).toBe(0); + expect(result.distance).toBe(Infinity); + }); + }); + + describe('A* algorithm', () => { + it('should find shortest path using A* algorithm', () => { + // Create a graph with positioned nodes + const nodeA = graph.addNode('A', new ex.Vector(0, 0)); + const nodeB = graph.addNode('B', new ex.Vector(3, 0)); + const nodeC = graph.addNode('C', new ex.Vector(0, 4)); + const nodeD = graph.addNode('D', new ex.Vector(3, 4)); + + // Add edges with weights + graph.addEdge(nodeA, nodeB); + graph.addEdge(nodeA, nodeC); + graph.addEdge(nodeB, nodeD); + graph.addEdge(nodeC, nodeD); + + /* + A -> B + | | + v v + C -> D + */ + + const result = graph.aStar(nodeA as ex.PositionNode, nodeD as ex.PositionNode); + + expect(result.path).toBeDefined(); + expect(result.path?.length).toBe(3); + expect(result.pathSteps).toBe(2); + expect(result.path?.[0]).toBe(nodeA as ex.PositionNode); + expect(result.path?.[1]).toBe(nodeB as ex.PositionNode); + expect(result.path?.[2]).toBe(nodeD as ex.PositionNode); + expect(result.distance).toBe(5); + }); + + it('should throw error when A* is used with non-PositionNodes', () => { + const nodeA = graph.addNode('A'); + const nodeB = graph.addNode('B'); + + // Type assertion to test error condition + expect(() => { + graph.aStar(nodeA as unknown as ex.PositionNode, nodeB as unknown as ex.PositionNode); + }).toThrow(new Error('A* algorithm requires PositionNode with position vectors')); + }); + + it('should return null path when no path exists in A*', () => { + // Create a graph with positioned nodes + const nodeA = graph.addNode('A', new ex.Vector(0, 0)); + const nodeB = graph.addNode('B', new ex.Vector(1, 1)); + // No edge connecting A and B + const result = graph.aStar(nodeA as ex.PositionNode, nodeB as ex.PositionNode); + + //path will be empty array and distance will be Infinity + expect(result.path.length).toBe(0); + expect(result.pathSteps).toBe(0); + expect(result.distance).toBe(Infinity); + }); + }); +});