• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 21673009514

04 Feb 2026 01:19PM UTC coverage: 88.731% (-0.03%) from 88.757%
21673009514

Pull #3681

github

web-flow
Merge 3c0086913 into df0c90a67
Pull Request #3681: feat: update graph component

5396 of 7349 branches covered (73.42%)

10 of 13 new or added lines in 1 file covered. (76.92%)

2 existing lines in 2 files now uncovered.

14866 of 16754 relevant lines covered (88.73%)

24493.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.67
/src/engine/math/graph.ts
1
import { Random } from './random';
2
import type { Vector } from './vector';
3

4
/**
5
 * A unique identifier for a graph node or edge.
6
 */
7
export type G_UUID = string & { readonly __brand: unique symbol };
8

9
/**
10
 * Interface to define data with a custom id
11
 */
12
export interface DataWithId {
13
  id: G_UUID;
14
}
15

16
/**
17
 * Return true if obj implement DataWithId interface
18
 * @param obj value to test
19
 * @returns true if obj implement DataWithId
20
 */
21
const isDataWithId = (obj: unknown): obj is DataWithId => {
248✔
22
  return (obj as DataWithId)?.id !== undefined && typeof (obj as DataWithId).id === 'string';
68!
23
};
24

25
interface EdgeOptionsWithWeight {
26
  weight: number;
27
  useEuclidean?: false;
28
  /**
29
   * Whether the edge is directed.
30
   * @default false
31
   */
32
  directed?: boolean;
33
}
34

35
interface EdgeOptionsWeightless {
36
  weight?: undefined;
37
  useEuclidean?: false | undefined;
38
  /**
39
   * Whether the edge is directed.
40
   * @default false
41
   */
42
  directed?: boolean;
43
}
44

45
interface EdgeOptionsWithEuclidean {
46
  weight?: undefined;
47
  useEuclidean: true;
48
  /**
49
   * Whether the edge is directed.
50
   * @default false
51
   */
52
  directed?: boolean;
53
}
54

55
/**
56
 * Options for creating a new edge in the graph.
57
 */
58
type EdgeOptions = EdgeOptionsWithWeight | EdgeOptionsWithEuclidean | EdgeOptionsWeightless;
59

60
/**
61
 * A weighted graph data structure.
62
 * @template T The type of data stored in each node.
63
 */
64
export class Graph<T> {
65
  private _nodes: Map<G_UUID, Node<T>>;
66
  private _edges: Set<Edge<T>>;
67
  adjacencyList: Map<G_UUID, Set<G_UUID>>;
68
  id: G_UUID = GraphUUId.generateUUID();
27✔
69

70
  /**
71
   * Constructs a new graph data structure.
72
   *
73
   * This constructor initializes an empty graph with no nodes or edges.
74
   */
75
  constructor() {
76
    this._nodes = new Map();
27✔
77
    this._edges = new Set();
27✔
78
    this.adjacencyList = new Map();
27✔
79
  }
80

81
  /**
82
   * Adds a new node to the graph with the given data.
83
   * @returns The newly created node.
84
   */
85
  addNode(data: T, position?: Vector): Node<T> | PositionNode<T> {
86
    let newNode;
87
    if (position) {
66✔
88
      newNode = new PositionNode(data, position);
6✔
89
    } else {
90
      newNode = new Node(data);
60✔
91
    }
92
    this._nodes.set(newNode.id, newNode);
66✔
93
    this.adjacencyList.set(newNode.id, new Set());
66✔
94
    return newNode;
66✔
95
  }
96

97
  /**
98
   * Adds multiple new nodes to the graph with the given data.
99
   * @returns A map of all nodes in the graph, including the newly created ones.
100
   */
101
  addNodes(nodes: T[]): Map<G_UUID, Node<T>> {
102
    for (const node of nodes) {
2✔
103
      this.addNode(node);
6✔
104
    }
105
    return this._nodes;
2✔
106
  }
107

108
  /**
109
   * Deletes a node from the graph along with all its associated edges.
110
   * This method removes the specified node and any edges connected to it
111
   * from the graph. It updates the internal structures to reflect these
112
   * changes.
113
   * @param node - The node to be deleted from the graph.
114
   * @returns A map of all remaining nodes in the graph.
115
   */
116
  deleteNode(node: Node<T>): Map<G_UUID, Node<T>> {
117
    //delete any edges tied to node
118
    const nodeEdges = node.edges;
1✔
119
    for (const edge of nodeEdges) {
1✔
120
      this.deleteEdge(edge);
1✔
121
    }
122

123
    this.adjacencyList.forEach((value, key) => {
1✔
124
      value.delete(node.id);
2✔
125
    });
126

127
    this._nodes.delete(node.id);
1✔
128
    this.adjacencyList.delete(node.id);
1✔
129
    return this._nodes;
1✔
130
  }
131

132
  /**
133
   * Adds a new edge between two nodes in the graph. If the edge already exists, it does not add a duplicate.
134
   * The function allows specifying edge options such as weight and directionality. For undirected edges,
135
   * it creates a duplicate edge in the reverse direction and links both edges as partners.
136
   * @param from - The source node of the edge.
137
   * @param to - The target node of the edge.
138
   * @param options - Optional settings for the edge, including weight and directionality.
139
   * @returns An array containing the created edge(s). If the edge is directed, the array contains one edge;
140
   *          if undirected, it contains both the original and the duplicate edge.
141
   */
142

143
  addEdge(from: Node<T>, to: Node<T>, options?: EdgeOptions): Edge<T>[] {
144
    //gaurd clauses
145
    const existingEdges = Array.from(this._edges).find((edge) => edge.source.id === from.id && edge.target.id === to.id);
107✔
146
    if (existingEdges) {
38✔
147
      return [];
1✔
148
    }
149

150
    let directed;
151

152
    if (options) {
37✔
153
      directed = 'directed' in options ? options.directed : false;
20✔
154
    } else {
155
      directed = false;
17✔
156
    }
157

158
    const newEdge = new Edge(from, to, options);
37✔
159

160
    this._edges.add(newEdge);
37✔
161
    from.registerNewEdge(newEdge);
37✔
162
    to.registerNewEdge(newEdge);
37✔
163
    this.adjacencyList.get(from.id)?.add(to.id);
37!
164

165
    if (!directed) {
37✔
166
      const duplicateEdge = new Edge(to, from, options);
33✔
167
      this.adjacencyList.get(to.id)?.add(from.id);
33!
168
      this._edges.add(duplicateEdge);
33✔
169
      to.registerNewEdge(duplicateEdge);
33✔
170
      from.registerNewEdge(duplicateEdge);
33✔
171
      //link the two edges together
172
      newEdge.linkWithPartner(duplicateEdge);
33✔
173
      duplicateEdge.linkWithPartner(newEdge);
33✔
174
      return [newEdge, duplicateEdge];
33✔
175
    }
176
    return [newEdge];
4✔
177
  }
178

179
  /**
180
   * Deletes an edge from the graph.
181
   *
182
   * This method removes the specified edge and its partner edge (if any) from the graph.
183
   * It updates the internal edge set and edge list accordingly. The source and target
184
   * nodes of the edge are also updated to reflect the removal of the edge.
185
   * @param edge - The edge to be deleted from the graph.
186
   */
187

188
  deleteEdge(edge: Edge<T>) {
189
    edge.source.breakEdge(edge);
3✔
190
    edge.target.breakEdge(edge);
3✔
191
    this._edges.delete(edge);
3✔
192

193
    const partnerEdge = edge.partnerEdge;
3✔
194
    if (partnerEdge) {
3✔
195
      partnerEdge.source.breakEdge(partnerEdge);
2✔
196
      partnerEdge.target.breakEdge(partnerEdge);
2✔
197
      this._edges.delete(partnerEdge);
2✔
198
    }
199
  }
200

201
  /**
202
   * The set of nodes in the graph, keyed by their UUID.
203
   *
204
   * The map returned by this property is a shallow copy of the internal map.
205
   * The nodes in this map are not frozen, and may be modified by the caller.
206
   * @returns A shallow copy of the graph's internal node map.
207
   */
208
  get nodes(): Map<G_UUID, Node<T>> {
209
    return this._nodes;
15✔
210
  }
211

212
  /**
213
   * Gets a node by its UUID.
214
   * @param id - The UUID of the node to be retrieved.
215
   * @returns The node with the specified UUID, or undefined if no such node exists.
216
   */
217
  getNode(id: G_UUID): Node<T> {
218
    return this._nodes.get(id)!;
1✔
219
  }
220

221
  /**
222
   * Retrieves the set of edges in the graph.
223
   *
224
   * The returned set is a shallow copy of the internal edge set.
225
   * Modifications to this set do not affect the graph's internal state.
226
   * @returns A set containing all edges in the graph.
227
   */
228
  get edges(): Set<Edge<T>> {
229
    return this._edges;
6✔
230
  }
231

232
  /**
233
   * Gets the neighbors of the given node.
234
   *
235
   * The returned array contains all of the nodes that are directly connected to the given node.
236
   * @param node - The node whose neighbors should be retrieved.
237
   * @returns An array of nodes that are directly connected to the given node.
238
   */
239
  getNeighbors(node: Node<T>): Node<T>[] {
240
    return Array.from(this.adjacencyList.get(node.id) ?? []).map((nid) => this.nodes.get(nid)!);
6!
241
  }
242

243
  /**
244
   * Checks if two nodes are connected by an edge.
245
   * @param node1 - The first node to check.
246
   * @param node2 - The second node to check.
247
   * @returns true if the nodes are connected, false if not.
248
   */
249
  areNodesConnected(node1: Node<T>, node2: Node<T>): boolean {
250
    return this.adjacencyList.get(node1.id)?.has(node2.id) ?? false;
3!
251
  }
252

253
  /**
254
   * Reset the graph
255
   *
256
   * Clear all nodes and edges from the graph.
257
   */
258
  reset(): void {
NEW
259
    this._nodes.clear();
×
NEW
260
    this._edges.clear();
×
NEW
261
    this.adjacencyList.clear();
×
262
  }
263

264
  /**
265
   * Performs a breadth-first search (BFS) on the graph starting from the given node.
266
   *
267
   * This method explores the graph layer by layer, starting from the specified node.
268
   * It visits all nodes that are directly connected to the start node before moving
269
   * on to the nodes at the next level of the graph.
270
   * @param startNode - The node to start the BFS from.
271
   * @returns An array of UUIDs representing the nodes that were visited during the search.
272
   *          The order of the nodes in the array corresponds to the order in which they
273
   *          were visited.
274
   */
275
  bfs(startNode: Node<T>): G_UUID[] {
276
    // Verify the start node exists in the graph
277
    if (!this._nodes.has(startNode.id)) {
2✔
278
      return [];
1✔
279
    }
280

281
    const queue: G_UUID[] = [startNode.id];
1✔
282
    const visited: Set<G_UUID> = new Set([startNode.id]);
1✔
283

284
    while (queue.length > 0) {
1✔
285
      const nodeId = queue.shift()!;
5✔
286
      const neighbors = this.adjacencyList.get(nodeId) || new Set();
5!
287
      for (const neighborId of neighbors) {
5✔
288
        if (!visited.has(neighborId)) {
10✔
289
          visited.add(neighborId);
4✔
290
          queue.push(neighborId);
4✔
291
        }
292
      }
293
    }
294
    return Array.from(visited);
1✔
295
  }
296

297
  /**
298
   * Performs a depth-first search (DFS) on the graph starting from the given node.
299
   *
300
   * This method explores the graph by traversing as far as possible along each
301
   * branch before backtracking. It visits all nodes that are reachable from the
302
   * start node.
303
   * @param startNode - The node to start the DFS from.
304
   * @param [visited] - A set of node IDs that have already been visited during
305
   *                    the search. This parameter is optional, and defaults to an
306
   *                    empty set.
307
   * @returns An array of UUIDs representing the nodes that were visited during the
308
   *          search. The order of the nodes in the array corresponds to the order
309
   *          in which they were visited.
310
   */
311
  dfs(startNode: Node<T>, visited: Set<string> = new Set()): G_UUID[] {
2✔
312
    const startId: G_UUID = startNode.id;
6✔
313
    if (!this._nodes.has(startId)) {
6✔
314
      return [];
1✔
315
    } // Invalid start node
316

317
    visited.add(startId);
5✔
318
    let result: G_UUID[] = [startId];
5✔
319

320
    for (const neighbor of this.adjacencyList.get(startId) ?? []) {
5!
321
      if (!visited.has(neighbor)) {
10✔
322
        result = result.concat(this.dfs(this._nodes.get(neighbor)!, visited));
4✔
323
      }
324
    }
325
    return result;
5✔
326
  }
327

328
  /**
329
   * Creates a new graph from an array of nodes, and adds them all to the graph.
330
   * @param nodes - The array of nodes to add to the graph.
331
   * @returns The newly created graph.
332
   */
333
  static createGraphFromNodes<T>(nodes: T[]): Graph<T> {
334
    const graph = new Graph<T>();
1✔
335
    graph.addNodes(nodes);
1✔
336
    return graph;
1✔
337
  }
338

339
  /**
340
   * Finds the shortest path between two nodes in the graph using Dijkstra's algorithm.
341
   *
342
   * This method calculates the shortest path from the specified start node to the
343
   * specified end node in the graph. It returns an object containing the path and
344
   * the total distance of the path.
345
   * @param startNode - The node from which the search for the shortest path begins.
346
   * @param endNode - The node where the search for the shortest path ends.
347
   * @returns An object containing:
348
   *   - `path`: An array of nodes representing the shortest path from startNode to endNode.
349
   *     If no path is found, this will be `null`.
350
   *   - `distance`: The total distance of the shortest path. If no path is found, this will
351
   *     be `Infinity`.
352
   */
353

354
  dijkstra(sourcenode: Node<T>): Array<{ node: Node<T>; distance: number; previous: Node<T> | null }> {
355
    const visited: Node<T>[] = [];
6✔
356
    const unvisited: Node<T>[] = [];
6✔
357
    const resultArray: Array<{ node: Node<T>; distance: number; previous: Node<T> | null }> = [];
6✔
358

359
    //fill unvisited and resultArray
360
    this.nodes.forEach((node) => {
6✔
361
      unvisited.push(node);
20✔
362
      resultArray.push({ node, distance: Infinity, previous: null });
20✔
363
    });
364

365
    //start with starting node
366
    //add startingnode to result array
367
    const startingNodeIndex = resultArray.findIndex((node) => node.node === sourcenode);
6✔
368
    if (startingNodeIndex === -1) {
6!
369
      return [];
×
370
    }
371
    resultArray[startingNodeIndex].distance = 0;
6✔
372

373
    visited.push(sourcenode);
6✔
374
    unvisited.splice(unvisited.indexOf(sourcenode), 1);
6✔
375

376
    let current = sourcenode;
6✔
377
    const currentEdges = current.edges;
6✔
378
    const filteredCurrentEdges: Edge<T>[] = Array.from(currentEdges).filter((edge: Edge<T>) => edge.target !== current);
12✔
379

380
    //update result array with distances, which is edge values
381

382
    for (const edge of filteredCurrentEdges) {
6✔
383
      const index = resultArray.findIndex((node) => node.node === edge.target);
15✔
384

385
      if (index === -1) {
6!
386
        return [];
×
387
      }
388
      resultArray[index].distance = edge.weight as number;
6✔
389
      resultArray[index].previous = current;
6✔
390
    }
391

392
    while (unvisited.length > 0) {
6✔
393
      //get list of unvisited available nodes
394
      let listOfAvailableNodes: Node<T>[] = [];
14✔
395
      let listofAvailableEntries: Array<{ node: Node<T>; distance: number; previous: Node<T> | null }> = [];
14✔
396
      listofAvailableEntries = resultArray.filter((node) => unvisited.includes(node.node));
64✔
397
      listOfAvailableNodes = listofAvailableEntries.map((node) => node.node);
32✔
398

399
      //loop through available nodes and find lowest distance to sourcenode
400
      let lowestDistance = Infinity;
14✔
401
      let lowestDistanceIndex = -1;
14✔
402

403
      if (listOfAvailableNodes.length > 0) {
14!
404
        for (let i = 0; i < listOfAvailableNodes.length; i++) {
14✔
405
          const unVisitiedNode = listOfAvailableNodes[i];
32✔
406

407
          const index = resultArray.findIndex((node) => node.node === unVisitiedNode);
121✔
408
          if (resultArray[index].distance < lowestDistance) {
32✔
409
            lowestDistance = resultArray[index].distance;
14✔
410
            lowestDistanceIndex = index;
14✔
411
          }
412
        }
413
      } else {
414
        //manage exception
415
        //choose node from unvisited list that has lowest distance to source node
416

417
        lowestDistance = Infinity;
×
418
        lowestDistanceIndex = -1;
×
419
        for (let i = 0; i < unvisited.length; i++) {
×
420
          const unVisitiedNode = unvisited[i];
×
421
          const index = resultArray.findIndex((node) => node.node === unVisitiedNode);
×
422
          if (resultArray[index].distance < lowestDistance) {
×
423
            lowestDistance = resultArray[index].distance;
×
424
            lowestDistanceIndex = index;
×
425
          }
426
        }
427
      }
428

429
      // add test for -1 index representing unreachable node
430
      if (lowestDistanceIndex === -1) {
14✔
431
        break;
3✔
432
      }
433

434
      current = resultArray[lowestDistanceIndex].node;
11✔
435
      let currentEdgesArray = Array.from(current.edges);
11✔
436

437
      //remove visited from currentEdges
438
      currentEdgesArray = currentEdgesArray.filter((edge: Edge<T>) => {
11✔
439
        return !visited.includes(edge.source) && !visited.includes(edge.target) && edge.target !== current;
44✔
440
      });
441

442
      visited.push(current);
11✔
443
      unvisited.splice(unvisited.indexOf(current), 1);
11✔
444

445
      //update result array with distances, which is edge values
446
      for (let i = 0; i < currentEdgesArray.length; i++) {
11✔
447
        const edge = currentEdgesArray[i];
8✔
448
        const index = resultArray.findIndex((node) => node.node === edge.target);
34✔
449

450
        //update cumulative distances
451
        const previousIndex = resultArray.findIndex((node) => node.node === edge.source);
23✔
452

453
        const previousDistance = resultArray[previousIndex].distance;
8✔
454
        const cumDistance = (previousDistance + edge.weight!) as number;
8✔
455

456
        if (cumDistance < resultArray[index].distance) {
8!
457
          resultArray[index].distance = cumDistance;
8✔
458
          resultArray[index].previous = current;
8✔
459
        }
460
      }
461
    }
462

463
    return resultArray;
6✔
464
  }
465

466
  /**
467
   * Finds the shortest path between two nodes in the graph using the Dijkstra method
468
   *
469
   * This method calculates the shortest path from the specified start node to the
470
   * specified end node in the graph. It returns an object containing the path and
471
   * the total distance of the path.
472
   * @param startingNode - The node from which the search for the shortest path begins.
473
   * @param endNode - The node where the search for the shortest path ends.
474
   * @returns An object containing:
475
   *   - `path`: An array of nodes representing the shortest path from startNode to endNode.
476
   *     If no path is found, this will be `null`.
477
   *   - `distance`: The total distance of the shortest path. If no path is found, this will
478
   *     be `Infinity`.
479
   */
480
  shortestPathDijkstra(startingNode: Node<T>, endNode: Node<T>): { path: Node<T>[]; distance: number } {
481
    const dAnalysis = this.dijkstra(startingNode);
3✔
482

483
    if (dAnalysis.length === 0) {
3!
UNCOV
484
      return { path: [], distance: Infinity };
×
485
    }
486
    //iterate through dAnalysis to plot shortest path to endnode
487
    const path: Node<T>[] = [];
3✔
488
    let current: Node<T> | null | undefined = endNode;
3✔
489
    const distance = dAnalysis.find((node) => node.node === endNode)?.distance as number;
12!
490
    if (distance === Infinity) {
3✔
491
      return { path: [], distance: Infinity };
1✔
492
    }
493
    while (current != null) {
2✔
494
      path.push(current);
8✔
495

496
      current = dAnalysis.find((node) => node.node === current)?.previous;
24!
497

498
      if (current == null) {
8✔
499
        break;
2✔
500
      }
501
    }
502
    path.reverse();
2✔
503
    return { path, distance };
2✔
504
  }
505

506
  /**
507
   * Finds the shortest path between two nodes in the graph using the A* algorithm.
508
   *
509
   * This method calculates the shortest path from the specified start node to the
510
   * specified end node in the graph. It returns an object containing the path and
511
   * the total distance of the path.
512
   * @param startNode - The node from which the search for the shortest path begins.
513
   * @param endNode - The node where the search for the shortest path ends.
514
   * @returns An object containing:
515
   *   - `path`: An array of nodes representing the shortest path from startNode to endNode.
516
   *     If no path is found, this will be `null`.
517
   *   - `distance`: The total distance of the shortest path. If no path is found, this will
518
   *     be `Infinity`.
519
   *   - `skippedNodes`: A set of all nodes that were skipped during the search (because they
520
   *     were not `PositionNode`s).
521
   */
522
  aStar(
523
    startNode: PositionNode<T>,
524
    endNode: PositionNode<T>
525
  ): {
526
    path: PositionNode<T>[] | null;
527
    pathSteps: number;
528
    distance: number;
529
    skippedNodes: Set<G_UUID>;
530
  } {
531
    // Make sure we're working with PositionNodes
532
    if (!('pos' in startNode) || !('pos' in endNode)) {
3✔
533
      throw new Error('A* algorithm requires PositionNode with position vectors');
1✔
534
    }
535

536
    // Initialize data structures
537
    const openSet: Set<G_UUID> = new Set([startNode.id]);
2✔
538
    const closedSet: Set<G_UUID> = new Set();
2✔
539
    const skippedNodes: Set<G_UUID> = new Set();
2✔
540

541
    // Track g scores (distance from start) and f scores (estimated total cost)
542
    const gScore: Map<G_UUID, number> = new Map();
2✔
543
    const hScore: Map<G_UUID, number> = new Map();
2✔
544
    const fScore: Map<G_UUID, number> = new Map();
2✔
545

546
    // Track the path
547
    const cameFrom: Map<G_UUID, G_UUID | null> = new Map();
2✔
548

549
    // Initialize scores
550

551
    //remap positionNodes from this._nodes where node is type of PositionNode
552
    const positionNodes: Map<G_UUID, PositionNode<T>> = new Map();
2✔
553
    for (const [nodeId, node] of this._nodes) {
2✔
554
      if ('pos' in node) {
6!
555
        positionNodes.set(nodeId, node as PositionNode<T>);
6✔
556
      } else {
557
        skippedNodes.add(nodeId);
×
558
      }
559
    }
560

561
    for (const [nodeId] of positionNodes) {
2✔
562
      gScore.set(nodeId, this._euclideanDistance(positionNodes.get(nodeId)!, startNode));
6✔
563
      hScore.set(nodeId, this._euclideanDistance(positionNodes.get(nodeId)!, endNode));
6✔
564
      fScore.set(nodeId, gScore.get(nodeId)! + hScore.get(nodeId)!);
6✔
565
      cameFrom.set(nodeId, null);
6✔
566
    }
567

568
    // Continue until we've visited all nodes or found the target
569
    while (openSet.size > 0) {
2✔
570
      // Find node with lowest fScore
571
      let currentId: G_UUID | null = null;
4✔
572
      let lowestFScore = Infinity;
4✔
573

574
      for (const nodeId of openSet) {
4✔
575
        const score = fScore.get(nodeId) || Infinity;
6!
576
        if (score < lowestFScore) {
6✔
577
          lowestFScore = score;
5✔
578
          currentId = nodeId;
5✔
579
        }
580
      }
581

582
      // If we can't find a node, there's no path
583
      if (currentId === null) {
4!
584
        break;
×
585
      }
586

587
      // If we found the target, we're done
588
      if (currentId === endNode.id) {
4✔
589
        // Reconstruct path
590
        const path: PositionNode<T>[] = [];
1✔
591
        let current: G_UUID | null = endNode.id;
1✔
592

593
        while (current !== null) {
1✔
594
          const node = this._nodes.get(current)! as PositionNode<T>;
3✔
595
          path.unshift(node);
3✔
596
          current = cameFrom.get(current)!;
3✔
597
        }
598

599
        return {
1✔
600
          path,
601
          pathSteps: path.length - 1,
602
          distance: gScore.get(endNode.id) || Infinity,
1!
603
          skippedNodes
604
        };
605
      }
606

607
      // Move from open to closed set
608
      openSet.delete(currentId);
3✔
609
      closedSet.add(currentId);
3✔
610

611
      // Get current node
612
      const currentNode = this._nodes.get(currentId)! as PositionNode<T>;
3✔
613

614
      // Check all neighbors
615
      const neighbors = this.getNeighbors(currentNode);
3✔
616

617
      for (const neighbor of neighbors) {
3✔
618
        const neighborId = neighbor.id;
4✔
619

620
        // Skip if neighbor has been processed
621
        if (closedSet.has(neighborId)) {
4✔
622
          continue;
1✔
623
        }
624

625
        // Ensure neighbor is a PositionNode
626
        const positionNeighbor = neighbor as PositionNode<T>;
3✔
627
        if (!('pos' in positionNeighbor)) {
3!
628
          continue;
×
629
        }
630

631
        // Find the edge connecting current to neighbor
632
        const edge: Edge<T> = Array.from(currentNode.edges).find((e: Edge<T>) => e.source.id === currentId && e.target.id === neighborId);
7✔
633

634
        if (!edge) {
3!
635
          continue;
×
636
        }
637

638
        cameFrom.set(neighborId, currentId);
3✔
639
        // Add to open set if not already there
640
        if (!openSet.has(neighborId)) {
3!
641
          openSet.add(neighborId);
3✔
642
        }
643
      }
644
    }
645

646
    // No path found
647
    return { path: [], pathSteps: 0, distance: Infinity, skippedNodes };
1✔
648
  }
649

650
  private _euclideanDistance(currentNode: PositionNode<T>, testNode: PositionNode<T>): number {
651
    const a = currentNode.pos;
12✔
652
    const b = testNode.pos;
12✔
653
    return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
12✔
654
  }
655
}
656

657
/**
658
 * Represents an edge in a graph, connecting two nodes.
659
 * @template T The type of data stored in the nodes connected by this edge.
660
 */
661
export class Edge<T> {
662
  private _id: G_UUID = GraphUUId.generateUUID();
70✔
663
  private _source: Node<T>;
664
  private _target: Node<T>;
665
  private _weight: number = 0;
70✔
666
  private _partnerEdge: Edge<T> | null = null; // Reference to the opposite direction edge
70✔
667

668
  constructor(source: Node<T>, target: Node<T>, config?: EdgeOptions) {
669
    this._source = source;
70✔
670
    this._target = target;
70✔
671
    if (config && config.weight) {
70✔
672
      this._weight = config.weight;
33✔
673
    } else if (config && config.useEuclidean) {
37!
674
      this._weight = (source as PositionNode<T>).pos.distance((target as PositionNode<T>).pos); //calc weight
×
675
    } else {
676
      this._weight = 0;
37✔
677
    }
678
  }
679

680
  linkWithPartner(partnerEdge: Edge<T>): void {
681
    this._partnerEdge = partnerEdge;
66✔
682
  }
683

684
  get id() {
685
    return this._id;
×
686
  }
687

688
  get source() {
689
    return this._source;
187✔
690
  }
691

692
  get target() {
693
    return this._target;
146✔
694
  }
695

696
  get weight() {
697
    return this._weight;
15✔
698
  }
699

700
  get partnerEdge() {
701
    return this._partnerEdge;
5✔
702
  }
703
}
704

705
/**
706
 * Represents a node in a graph, with a unique identifier and optional data.
707
 * @template T The type of data stored in this node.
708
 */
709
export class Node<T> {
710
  private _id: G_UUID;
711
  private _data: T;
712
  private _edges: Set<Edge<T>>;
713

714
  constructor(data: T) {
715
    this._id = isDataWithId(data) ? data.id : GraphUUId.generateUUID();
68!
716
    this._data = data;
68✔
717
    this._edges = new Set();
68✔
718
  }
719

720
  get id(): G_UUID {
721
    return this._id;
608✔
722
  }
723

724
  get data() {
725
    return this._data;
1✔
726
  }
727

728
  get edges() {
729
    return this._edges;
23✔
730
  }
731

732
  registerNewEdge(newEdge: Edge<T>) {
733
    this._edges.add(newEdge);
140✔
734
  }
735

736
  breakEdge(edge: Edge<T>) {
737
    this._edges.delete(edge);
10✔
738
  }
739

740
  getConnectedNodes(): Node<T>[] {
741
    return Array.from(this._edges).map((edge) => edge.target);
×
742
  }
743
}
744

745
/**
746
 * Represents a node in a graph with a unique identifier, optional data, and a position in space.
747
 * @template T The type of data stored in this node.
748
 * @augments {Node<T>}
749
 */
750
export class PositionNode<T> extends Node<T> {
751
  pos: Vector;
752

753
  constructor(data: T, pos: Vector) {
754
    super(data);
6✔
755
    this.pos = pos;
6✔
756
  }
757
}
758

759
class GraphUUId {
248✔
760
  static rng: Random = new Random();
761

762
  static generateUUID(): G_UUID {
763
    const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
165✔
764

765
    const generatedUuid = uuid.replace(/[xy]/g, function (c) {
165✔
766
      const r = (GraphUUId.rng.next() * 16) | 0;
5,115✔
767
      const v = c === 'x' ? r : (r & 0x3) | 0x8;
5,115✔
768
      return v.toString(16);
5,115✔
769
    });
770

771
    // Type assertion to convert string to branded type
772
    return generatedUuid as G_UUID;
165✔
773
  }
774
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc